forked from wangziqi/gongxue-base
feat: add referral conversion report
This commit is contained in:
10
README.md
10
README.md
@@ -20,7 +20,7 @@
|
|||||||
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 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 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略、客资队列和学生批量 CRM 跟进推送。
|
||||||
- `apps/worker` 后台任务进程:CRM webhook 队列消费、`lead.created` 客资事件、`student.crm_push` 学生跟进事件、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;student-supervision worker 可按租户督导规则模板定时生成学习跟进任务;commerce worker 可补偿查询微信/支付宝支付和退款状态;provider-bills worker 可下载微信/支付宝官方账单并导入资金对账;platform-billing worker 可自动为即将到期且未开票的 SaaS 订阅生成服务费账单并写审计;platform-usage worker 可按月从权威业务表自动采集学生数、活跃学生、题量、资源数、存储 GB、视频数、视频播放、视频次数消耗、已支付订单、GMV 和有效权益,并写入用量台账和审计;platform-usage-overage worker 可按上月账期自动生成 `usage_overage` 账单,执行失败会写入脱敏平台审计日志并由审计告警 worker 转为开放告警;platform-dunning worker 可扫描逾期未结清服务费账单、标记 overdue、写内部催缴记录和审计;platform-dunning-notifications worker 可把内部催缴记录按平台渠道推送到 generic/钉钉/飞书/企微 webhook,并记录幂等发送事件;platform-audit-alerts worker 可把高风险平台审计动作和自动化失败转换为内部告警并递归脱敏告警 details;platform-audit-notifications worker 可把开放审计告警按平台渠道推送到 generic/钉钉/飞书/企微 webhook,并记录幂等发送事件;assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。
|
- `apps/worker` 后台任务进程:CRM webhook 队列消费、`lead.created` 客资事件、`student.crm_push` 学生跟进事件、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;student-supervision worker 可按租户督导规则模板定时生成学习跟进任务;commerce worker 可补偿查询微信/支付宝支付和退款状态;provider-bills worker 可下载微信/支付宝官方账单并导入资金对账;platform-billing worker 可自动为即将到期且未开票的 SaaS 订阅生成服务费账单并写审计;platform-usage worker 可按月从权威业务表自动采集学生数、活跃学生、题量、资源数、存储 GB、视频数、视频播放、视频次数消耗、已支付订单、GMV 和有效权益,并写入用量台账和审计;platform-usage-overage worker 可按上月账期自动生成 `usage_overage` 账单,执行失败会写入脱敏平台审计日志并由审计告警 worker 转为开放告警;platform-dunning worker 可扫描逾期未结清服务费账单、标记 overdue、写内部催缴记录和审计;platform-dunning-notifications worker 可把内部催缴记录按平台渠道推送到 generic/钉钉/飞书/企微 webhook,并记录幂等发送事件;platform-audit-alerts worker 可把高风险平台审计动作和自动化失败转换为内部告警并递归脱敏告警 details;platform-audit-notifications worker 可把开放审计告警按平台渠道推送到 generic/钉钉/飞书/企微 webhook,并记录幂等发送事件;assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。
|
||||||
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态、CSV/JSON 导出、打款凭证登记/复核和权限隔离。
|
- 销售/代理分佣和转化看板基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态、CSV/JSON 导出、打款凭证登记/复核、销售/代理转化报表、近期未成交客资、CRM 失败/跟进积压和本人/全局权限隔离。
|
||||||
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
|
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
|
||||||
- 资金对账、异常订单和财务凭证闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,也可创建微信/支付宝官方账单下载任务;后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕;`/api/commerce/operations/anomalies` 聚合异常订单风险,`/api/commerce/adjustment-vouchers*` 支持人工调整凭证、复核、事件轨迹和报表。工单和凭证只做财务审核闭环,不直接修改订单、支付、退款或权益。
|
- 资金对账、异常订单和财务凭证闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,也可创建微信/支付宝官方账单下载任务;后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕;`/api/commerce/operations/anomalies` 聚合异常订单风险,`/api/commerce/adjustment-vouchers*` 支持人工调整凭证、复核、事件轨迹和报表。工单和凭证只做财务审核闭环,不直接修改订单、支付、退款或权益。
|
||||||
- PocketBase SQLite 只读导出、JSON dry-run、标准化导入和导入后校验脚本;真实旧库 248555 条业务记录已能在干净本地 Supabase 中完成全量导入。
|
- PocketBase SQLite 只读导出、JSON dry-run、标准化导入和导入后校验脚本;真实旧库 248555 条业务记录已能在干净本地 Supabase 中完成全量导入。
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载、字段映射 API 和 Taro 租户内容页第一版导入操作台已完成。
|
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载、字段映射 API 和 Taro 租户内容页第一版导入操作台已完成。
|
||||||
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
||||||
- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。
|
- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。
|
||||||
- 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩系统触发勋章已可联调;积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成,Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。学生激励默认以后台配置勋章自动发放为主,排行榜默认不开启也不在学生端默认请求。CRM 死信运营第一版已完成失败池、日志脱敏、重试/忽略和审计闭环。后续还要补更细活动效果看板、外部微信订阅消息/短信推送、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、外部失败告警升级、销售转化看板、公共题库版本通知和冲突处理操作台。
|
- 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩系统触发勋章已可联调;积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成,Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。学生激励默认以后台配置勋章自动发放为主,排行榜默认不开启也不在学生端默认请求。CRM 死信运营第一版已完成失败池、日志脱敏、重试/忽略和审计闭环,销售/代理转化看板第一版已完成。后续还要补更细活动效果看板、外部微信订阅消息/短信推送、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、外部失败告警升级、公共题库版本通知和冲突处理操作台。
|
||||||
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料、独立消息中心和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:<uuid>`/`content_asset:<uuid>` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、学习概览、掌握率、收藏数、计划拆分、卡片翻转、发音、美/英音切换和本地位置恢复第一版,知识手册已接章节内搜索、安全文本摘要高亮和目录定位第一版,分数线已接目标地区默认筛选、院校/专业/年份 chip、租户动态字段筛选、结果字段 chip 和趋势摘要第一版,AI 择校已接报告生成、历史报告和 Markdown/HTML 导出第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习、男女预设头像选择、积分任务/兑换/积分明细和消息中心摘要第一版,独立消息中心已接状态/类型筛选、批量已读、归档/忽略和站内安全跳转第一版;学生端不默认请求排行榜,仅在租户显式开启 `enableLeaderboard` 并完成压测后进入独立排行榜页或活动页;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,学生运营页已接跟进看板、学习督导自动化、督导规则保存和批量 CRM 推送第一版,营销中心已接 CRM 配置、队列筛选、死信失败池、日志查看、重试/忽略、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权、平台员工管理,以及创建租户、租户详情、状态变更、账务资料维护、平台员工创建/编辑/禁用恢复、权限点勾选、平台审计查询/CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、催缴外部通知渠道/事件摘要、用量和题库授权第一版写操作。
|
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料、独立消息中心和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:<uuid>`/`content_asset:<uuid>` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、学习概览、掌握率、收藏数、计划拆分、卡片翻转、发音、美/英音切换和本地位置恢复第一版,知识手册已接章节内搜索、安全文本摘要高亮和目录定位第一版,分数线已接目标地区默认筛选、院校/专业/年份 chip、租户动态字段筛选、结果字段 chip 和趋势摘要第一版,AI 择校已接报告生成、历史报告和 Markdown/HTML 导出第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习、男女预设头像选择、积分任务/兑换/积分明细和消息中心摘要第一版,独立消息中心已接状态/类型筛选、批量已读、归档/忽略和站内安全跳转第一版;学生端不默认请求排行榜,仅在租户显式开启 `enableLeaderboard` 并完成压测后进入独立排行榜页或活动页;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,学生运营页已接跟进看板、学习督导自动化、督导规则保存和批量 CRM 推送第一版,营销中心已接 CRM 配置、队列筛选、死信失败池、日志查看、重试/忽略、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权、平台员工管理,以及创建租户、租户详情、状态变更、账务资料维护、平台员工创建/编辑/禁用恢复、权限点勾选、平台审计查询/CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、催缴外部通知渠道/事件摘要、用量和题库授权第一版写操作。
|
||||||
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
| 背单词/知识手册/分数线 | √ 可联调 | 列表、学习/阅读、动态筛选、JSON/CSV/Excel 导入和 Taro 第一版页面已具备 |
|
| 背单词/知识手册/分数线 | √ 可联调 | 列表、学习/阅读、动态筛选、JSON/CSV/Excel 导入和 Taro 第一版页面已具备 |
|
||||||
| 会员/订单/优惠券/激活码 | √ 可联调 | 下单、订单详情/状态轮询、优惠券规则/核销、激活码、权益、退款状态机和对账地基已完成 |
|
| 会员/订单/优惠券/激活码 | √ 可联调 | 下单、订单详情/状态轮询、优惠券规则/核销、激活码、权益、退款状态机和对账地基已完成 |
|
||||||
| 国内登录/支付 provider | √ 本地可跑,待真实密钥 | 阿里云/腾讯云短信、微信小程序/网页、QQ、微信支付、支付宝均有 adapter/fake 测试;生产账号和回调域名上云后联调 |
|
| 国内登录/支付 provider | √ 本地可跑,待真实密钥 | 阿里云/腾讯云短信、微信小程序/网页、QQ、微信支付、支付宝均有 adapter/fake 测试;生产账号和回调域名上云后联调 |
|
||||||
| 租户后台运营 | √ 可联调 | 学生/班级、内容导入导出、营销、优惠券、积分、勋章、CRM、分佣、财务运营、主题和角色模板已具备第一版 |
|
| 租户后台运营 | √ 可联调 | 学生/班级、内容导入导出、营销、优惠券、积分、勋章、CRM、分佣、销售/代理转化、财务运营、主题和角色模板已具备第一版 |
|
||||||
| 平台 SaaS 账务 | √ 可联调 | 套餐、订阅、订阅账单、自动计费、用量采集、超额账单 API/worker、收款、逾期催缴、外部通知和审计已具备 |
|
| 平台 SaaS 账务 | √ 可联调 | 套餐、订阅、订阅账单、自动计费、用量采集、超额账单 API/worker、收款、逾期催缴、外部通知和审计已具备 |
|
||||||
| 公共题库商业化 | √ 可联调 | 平台题库授权、单地区/全国 SaaS 范围、租户采纳、手动/自动同步、冲突处理和通知已完成基础闭环 |
|
| 公共题库商业化 | √ 可联调 | 平台题库授权、单地区/全国 SaaS 范围、租户采纳、手动/自动同步、冲突处理和通知已完成基础闭环 |
|
||||||
| 对象存储/资料安全 | √ 可联调,待生产 AV/CDN | OSS/COS/Supabase Storage 签名、上传确认、短签名预览下载、水印 traceId、复检和安全扫描地基已完成 |
|
| 对象存储/资料安全 | √ 可联调,待生产 AV/CDN | OSS/COS/Supabase Storage 签名、上传确认、短签名预览下载、水印 traceId、复检和安全扫描地基已完成 |
|
||||||
@@ -644,7 +644,7 @@ npm run audit:runtime
|
|||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
结果:通过。最近一轮真实迁移专项验证已通过 `npx supabase db reset`、`npm run pb:import:json`、`npm run pb:import:validate`、`npm run pb:import:sample` 和 `npm run check:importer`;`pb:import:sample` 当前为 0 failures、6 warnings、1 skipped、39 passed;production dry-run 仍按预期返回非 0,因为旧数据本身还剩订单缺用户和手册章节缺归属两个 blocker。`npm run test:auth:remote-smoke` 覆盖远程 Auth/JWKS 验收脚本自身。`npm run test:rls` 覆盖 75 条运行时 RLS 断言,包含主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝。`npm run test:api` 覆盖平台细粒度权限、公共题库跨租户同步运营状态、资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览、访问记录查询、安全扫描门禁、官方账单下载任务权限和脱敏响应、异常订单运营台、人工调整凭证提交/复核/事件/报表、平台账单逾期 dry-run/催缴记录、平台审计告警查询/状态更新/越权拒绝/敏感 details 脱敏、平台审计告警通知渠道/事件查询和密钥不回显、平台催缴通知渠道/事件查询和密钥不回显、租户隔离,以及凭证审批不修改订单/支付/权益。`npm run test:worker:commerce` 覆盖支付/退款补偿、微信/支付宝官方账单下载、账单 hash 校验、导入 `provider_download` 对账批次和密钥不泄露。`npm run test:worker:platform-billing` 覆盖平台 SaaS 订阅自动计费、重复开票保护、账单明细和审计。`npm run test:worker:platform-usage` 覆盖平台 SaaS 月度用量自动采集、11 类指标、手工调整记录不覆盖和重复运行幂等。`npm run test:worker:platform-usage-overage` 覆盖平台 SaaS 超额账单明细、重复开票保护、非法账期失败审计、审计告警生成和敏感错误信息脱敏。`npm run test:worker:platform-dunning` 覆盖平台 SaaS 逾期账单标记、内部催缴记录、租户 `past_due` 状态和每日催缴幂等。`npm run test:worker:platform-dunning-notifications` 覆盖平台 SaaS 催缴外部通知入队、generic webhook 发送、幂等、防重复、联系方式掩码、签名密钥不泄露和请求 payload 脱敏。`npm run test:worker:platform-audit-alerts` 覆盖平台审计告警生成、规则匹配、幂等、防重复和告警 details 脱敏。`npm run test:worker:platform-audit-notifications` 覆盖平台审计告警外部通知入队、generic webhook 发送、幂等、防重复、签名密钥不泄露和请求 payload 脱敏。`npm run test:worker:assets` 覆盖托管资源复检、内置安全扫描、外部 HTTP scanner 通过/失败/不可用 fail-closed、扫描失败/跳过事件和异常资源自动下架。`npm run test:worker:public-banks` 覆盖公共题库自动同步、失败通知和恢复自动关闭。`npm run test:worker:exports` 覆盖导出 worker 生成可信资源并标记 `securityScanStatus=passed`。`npm run audit:runtime` 当前为 0 vulnerabilities;Excel 解析已从 `exceljs` 切换为 `read-excel-file`,避免生产运行时携带 `exceljs -> uuid` 的已知中危依赖。
|
结果:通过。最近一轮真实迁移专项验证已通过 `npx supabase db reset`、`npm run pb:import:json`、`npm run pb:import:validate`、`npm run pb:import:sample` 和 `npm run check:importer`;`pb:import:sample` 当前为 0 failures、6 warnings、1 skipped、39 passed;production dry-run 仍按预期返回非 0,因为旧数据本身还剩订单缺用户和手册章节缺归属两个 blocker。`npm run test:auth:remote-smoke` 覆盖远程 Auth/JWKS 验收脚本自身。`npm run test:rls` 覆盖 75 条运行时 RLS 断言,包含主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝。`npm run test:api` 覆盖平台细粒度权限、公共题库跨租户同步运营状态、资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览、访问记录查询、安全扫描门禁、官方账单下载任务权限和脱敏响应、异常订单运营台、人工调整凭证提交/复核/事件/报表、销售/代理转化报表本人/全局权限、平台账单逾期 dry-run/催缴记录、平台审计告警查询/状态更新/越权拒绝/敏感 details 脱敏、平台审计告警通知渠道/事件查询和密钥不回显、平台催缴通知渠道/事件查询和密钥不回显、租户隔离,以及凭证审批不修改订单/支付/权益。`npm run test:worker:commerce` 覆盖支付/退款补偿、微信/支付宝官方账单下载、账单 hash 校验、导入 `provider_download` 对账批次和密钥不泄露。`npm run test:worker:platform-billing` 覆盖平台 SaaS 订阅自动计费、重复开票保护、账单明细和审计。`npm run test:worker:platform-usage` 覆盖平台 SaaS 月度用量自动采集、11 类指标、手工调整记录不覆盖和重复运行幂等。`npm run test:worker:platform-usage-overage` 覆盖平台 SaaS 超额账单明细、重复开票保护、非法账期失败审计、审计告警生成和敏感错误信息脱敏。`npm run test:worker:platform-dunning` 覆盖平台 SaaS 逾期账单标记、内部催缴记录、租户 `past_due` 状态和每日催缴幂等。`npm run test:worker:platform-dunning-notifications` 覆盖平台 SaaS 催缴外部通知入队、generic webhook 发送、幂等、防重复、联系方式掩码、签名密钥不泄露和请求 payload 脱敏。`npm run test:worker:platform-audit-alerts` 覆盖平台审计告警生成、规则匹配、幂等、防重复和告警 details 脱敏。`npm run test:worker:platform-audit-notifications` 覆盖平台审计告警外部通知入队、generic webhook 发送、幂等、防重复、签名密钥不泄露和请求 payload 脱敏。`npm run test:worker:assets` 覆盖托管资源复检、内置安全扫描、外部 HTTP scanner 通过/失败/不可用 fail-closed、扫描失败/跳过事件和异常资源自动下架。`npm run test:worker:public-banks` 覆盖公共题库自动同步、失败通知和恢复自动关闭。`npm run test:worker:exports` 覆盖导出 worker 生成可信资源并标记 `securityScanStatus=passed`。`npm run audit:runtime` 当前为 0 vulnerabilities;Excel 解析已从 `exceljs` 切换为 `read-excel-file`,避免生产运行时携带 `exceljs -> uuid` 的已知中危依赖。
|
||||||
|
|
||||||
注意:`apps/taro` 是静态构建工程,线上发布 `apps/taro/dist/**`,不发布 `node_modules`。Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 提示,不能用 `npm audit fix --force` 降级到 Taro 3 破坏构建;上线验收时以 `audit:runtime`、构建产物、前端密钥检查和静态服务器配置为准,并持续跟进 Taro 官方修复。
|
注意:`apps/taro` 是静态构建工程,线上发布 `apps/taro/dist/**`,不发布 `node_modules`。Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 提示,不能用 `npm audit fix --force` 降级到 Taro 3 破坏构建;上线验收时以 `audit:runtime`、构建产物、前端密钥检查和静态服务器配置为准,并持续跟进 Taro 官方修复。
|
||||||
|
|
||||||
@@ -656,6 +656,6 @@ git diff --check
|
|||||||
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、审计告警通知升级策略、催缴通知操作台细节和小程序兼容验证。
|
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、审计告警通知升级策略、催缴通知操作台细节和小程序兼容验证。
|
||||||
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
|
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
|
||||||
4. 题库导出模板精排、导出操作台、导入字段映射 UI 和复检结果操作台;继续对真实迁移数据做题目、订单、权益、错题、资料和视频抽样验收。
|
4. 题库导出模板精排、导出操作台、导入字段映射 UI 和复检结果操作台;继续对真实迁移数据做题目、订单、权益、错题、资料和视频抽样验收。
|
||||||
5. 上云后接真实 OAuth/短信/支付生产账号、回调域名和真实生产账单抽样验收;本地阶段继续用 mock/fake provider 验证回调后业务链路、幂等、审计、密钥不泄露和权益开通/撤销。后续还要补真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化。排行榜不是默认主线功能,仅在租户显式购买/开启活动并完成压测后,才进入防刷、日/周榜预聚合和运营看板开发。
|
5. 上云后接真实 OAuth/短信/支付生产账号、回调域名和真实生产账单抽样验收;本地阶段继续用 mock/fake provider 验证回调后业务链路、幂等、审计、密钥不泄露和权益开通/撤销。后续还要补真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控、连续签到奖励深化、销售/代理转化预聚合和更细团队数据范围。排行榜不是默认主线功能,仅在租户显式购买/开启活动并完成压测后,才进入防刷、日/周榜预聚合和运营看板开发。
|
||||||
|
|
||||||
旧原生小程序前端位于 `F:\project\参考\旧题库小程序前端文件`,后续 Taro H5/小程序补体验时只作为页面状态、微信平台能力和交互参考,不继承旧 PocketBase 直连和旧鉴权逻辑。
|
旧原生小程序前端位于 `F:\project\参考\旧题库小程序前端文件`,后续 Taro H5/小程序补体验时只作为页面状态、微信平台能力和交互参考,不继承旧 PocketBase 直连和旧鉴权逻辑。
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
crmQueueLogsRoute,
|
crmQueueLogsRoute,
|
||||||
crmQueueRoute,
|
crmQueueRoute,
|
||||||
referralBindRoute,
|
referralBindRoute,
|
||||||
|
referralConversionReportRoute,
|
||||||
referralInviteCodeRoute,
|
referralInviteCodeRoute,
|
||||||
referralManualBindRoute,
|
referralManualBindRoute,
|
||||||
referralQrcodeRoute,
|
referralQrcodeRoute,
|
||||||
@@ -40,6 +41,7 @@ export const referralRoutes: RouteDefinition[] = [
|
|||||||
['POST', '/api/referral/bind', referralBindRoute],
|
['POST', '/api/referral/bind', referralBindRoute],
|
||||||
['GET', '/api/referral/stats', referralStatsRoute],
|
['GET', '/api/referral/stats', referralStatsRoute],
|
||||||
['GET', '/api/referral/sales-stats', referralSalesStatsRoute],
|
['GET', '/api/referral/sales-stats', referralSalesStatsRoute],
|
||||||
|
['GET', '/api/referral/conversion-report', referralConversionReportRoute],
|
||||||
['GET', '/api/referral/sales-clients', referralSalesClientsRoute],
|
['GET', '/api/referral/sales-clients', referralSalesClientsRoute],
|
||||||
['POST', '/api/referral/manual-bind', referralManualBindRoute],
|
['POST', '/api/referral/manual-bind', referralManualBindRoute],
|
||||||
['GET', '/api/referral/team', referralTeamRoute],
|
['GET', '/api/referral/team', referralTeamRoute],
|
||||||
|
|||||||
@@ -154,6 +154,55 @@ function centsToAmount(cents: number) {
|
|||||||
return Math.round(cents) / 100;
|
return Math.round(cents) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function intValue(value: unknown, fallback = 0) {
|
||||||
|
const parsed = Number(value ?? fallback);
|
||||||
|
return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateValue(value: string, fallback: string) {
|
||||||
|
const candidate = value || fallback;
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) {
|
||||||
|
throw new HttpError(400, 'Date must use YYYY-MM-DD format', 'INVALID_DATE');
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shanghaiDateKey(date = new Date()) {
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
const parts = Object.fromEntries(formatter.formatToParts(date).map(part => [part.type, part.value]));
|
||||||
|
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysBetweenInclusive(startDate: string, endDate: string) {
|
||||||
|
const start = new Date(`${startDate}T00:00:00+08:00`).getTime();
|
||||||
|
const end = new Date(`${endDate}T00:00:00+08:00`).getTime();
|
||||||
|
return Math.floor((end - start) / 86400000) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function conversionPeriodFromParams(ctx: RequestContext) {
|
||||||
|
const today = shanghaiDateKey();
|
||||||
|
const startDate = dateValue(stringParam(ctx, 'startDate'), today.slice(0, 8) + '01');
|
||||||
|
const endDate = dateValue(stringParam(ctx, 'endDate'), today);
|
||||||
|
if (startDate > endDate) {
|
||||||
|
throw new HttpError(400, 'startDate must be before or equal to endDate', 'INVALID_DATE_RANGE');
|
||||||
|
}
|
||||||
|
const days = daysBetweenInclusive(startDate, endDate);
|
||||||
|
if (days > 180) {
|
||||||
|
throw new HttpError(400, 'Conversion report range cannot exceed 180 days', 'REPORT_RANGE_TOO_LARGE');
|
||||||
|
}
|
||||||
|
return { startDate, endDate, days };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratio(numerator: number, denominator: number) {
|
||||||
|
if (!denominator) return 0;
|
||||||
|
return Number((numerator / denominator).toFixed(4));
|
||||||
|
}
|
||||||
|
|
||||||
function canViewAllReferral(auth: TenantAdminAuth) {
|
function canViewAllReferral(auth: TenantAdminAuth) {
|
||||||
return hasTenantPermission(auth, 'referral:read');
|
return hasTenantPermission(auth, 'referral:read');
|
||||||
}
|
}
|
||||||
@@ -162,6 +211,14 @@ function canViewSelfReferral(auth: TenantAdminAuth) {
|
|||||||
return hasTenantPermission(auth, 'referral:self') || canViewAllReferral(auth);
|
return hasTenantPermission(auth, 'referral:self') || canViewAllReferral(auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restrictReferralScope(auth: TenantAdminAuth, requestedReferrerId: string | null) {
|
||||||
|
if (canViewAllReferral(auth)) return requestedReferrerId;
|
||||||
|
if (requestedReferrerId && requestedReferrerId !== auth.userId) {
|
||||||
|
throw new HttpError(403, 'Only own referral conversion data can be viewed', 'REFERRAL_SCOPE_REQUIRED');
|
||||||
|
}
|
||||||
|
return auth.userId;
|
||||||
|
}
|
||||||
|
|
||||||
async function userHasTenantMembership(client: pg.PoolClient, tenantId: string, userId: string) {
|
async function userHasTenantMembership(client: pg.PoolClient, tenantId: string, userId: string) {
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`
|
`
|
||||||
@@ -955,6 +1012,574 @@ export async function referralSalesStatsRoute(ctx: RequestContext) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function referralConversionReportRoute(ctx: RequestContext) {
|
||||||
|
const auth = await requireTenantAdmin(ctx);
|
||||||
|
if (!canViewSelfReferral(auth)) {
|
||||||
|
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate, days } = conversionPeriodFromParams(ctx);
|
||||||
|
const requestedReferrerId = optionalUuidString(stringParam(ctx, 'referrerUserId'), 'referrerUserId') || null;
|
||||||
|
const scopedReferrerId = restrictReferralScope(auth, requestedReferrerId);
|
||||||
|
const limit = intParam(ctx, 'limit', 20, 100);
|
||||||
|
const params = [auth.tenantId, startDate, endDate, scopedReferrerId, limit];
|
||||||
|
|
||||||
|
const byReferrerRows = await query<Record<string, unknown>>(
|
||||||
|
`
|
||||||
|
with settings as (
|
||||||
|
select coalesce(default_rate, 0.2000)::numeric(6,4) as default_rate
|
||||||
|
from public.tenant_commission_settings
|
||||||
|
where tenant_id = $1
|
||||||
|
union all
|
||||||
|
select 0.2000::numeric(6,4)
|
||||||
|
limit 1
|
||||||
|
),
|
||||||
|
referrers as (
|
||||||
|
select distinct on (tm.user_id)
|
||||||
|
tm.user_id, tm.role, u.username, u.name, u.phone, rc.code::text as invite_code
|
||||||
|
from public.tenant_memberships tm
|
||||||
|
join public.platform_users u on u.id = tm.user_id
|
||||||
|
left join public.referral_codes rc on rc.tenant_id = tm.tenant_id and rc.user_id = tm.user_id
|
||||||
|
where tm.tenant_id = $1
|
||||||
|
and tm.status = 'active'
|
||||||
|
and tm.role in ('tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent')
|
||||||
|
and ($4::uuid is null or tm.user_id = $4::uuid)
|
||||||
|
order by tm.user_id,
|
||||||
|
case tm.role
|
||||||
|
when 'sales' then 1
|
||||||
|
when 'agent' then 2
|
||||||
|
when 'tenant_operator' then 3
|
||||||
|
when 'teacher' then 4
|
||||||
|
else 9
|
||||||
|
end
|
||||||
|
),
|
||||||
|
lead_base as (
|
||||||
|
select rl.*
|
||||||
|
from public.referral_leads rl
|
||||||
|
where rl.tenant_id = $1
|
||||||
|
and rl.status = 'protected'
|
||||||
|
and rl.referrer_user_id is not null
|
||||||
|
and ($4::uuid is null or rl.referrer_user_id = $4::uuid)
|
||||||
|
),
|
||||||
|
new_leads as (
|
||||||
|
select *
|
||||||
|
from lead_base
|
||||||
|
where bound_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
and bound_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
),
|
||||||
|
order_sources as (
|
||||||
|
select 'order'::text as source_type,
|
||||||
|
o.id as source_id,
|
||||||
|
coalesce(o.paid_at, o.updated_at, o.created_at) as source_paid_at,
|
||||||
|
o.user_id as student_user_id,
|
||||||
|
lb.referrer_user_id,
|
||||||
|
o.amount_cents::integer as gross_amount_cents,
|
||||||
|
null::numeric(6,4) as batch_rate
|
||||||
|
from public.orders o
|
||||||
|
join lead_base lb on lb.tenant_id = o.tenant_id
|
||||||
|
and lb.student_user_id = o.user_id
|
||||||
|
and coalesce(o.paid_at, o.updated_at, o.created_at) >= lb.bound_at
|
||||||
|
where o.tenant_id = $1
|
||||||
|
and o.status = 'paid'
|
||||||
|
and o.user_id is not null
|
||||||
|
and coalesce(o.paid_at, o.updated_at, o.created_at) >= ($2::date::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
and coalesce(o.paid_at, o.updated_at, o.created_at) < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
),
|
||||||
|
code_sources as (
|
||||||
|
select 'activation_code'::text as source_type,
|
||||||
|
ac.id as source_id,
|
||||||
|
ac.used_at as source_paid_at,
|
||||||
|
ac.used_by as student_user_id,
|
||||||
|
lb.referrer_user_id,
|
||||||
|
greatest(coalesce(ac.unit_price_cents, cb.default_unit_price_cents, 0), 0)::integer as gross_amount_cents,
|
||||||
|
cb.commission_rate as batch_rate
|
||||||
|
from public.activation_codes ac
|
||||||
|
left join public.code_batches cb on cb.tenant_id = ac.tenant_id and cb.id = ac.batch_id
|
||||||
|
join lead_base lb on lb.tenant_id = ac.tenant_id
|
||||||
|
and lb.student_user_id = ac.used_by
|
||||||
|
and ac.used_at >= lb.bound_at
|
||||||
|
where ac.tenant_id = $1
|
||||||
|
and ac.is_used is true
|
||||||
|
and ac.used_by is not null
|
||||||
|
and ac.used_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
and ac.used_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
),
|
||||||
|
sources as (
|
||||||
|
select * from order_sources
|
||||||
|
union all
|
||||||
|
select * from code_sources
|
||||||
|
),
|
||||||
|
referrer_members as (
|
||||||
|
select distinct on (tm.user_id)
|
||||||
|
tm.user_id, tm.commission_rate
|
||||||
|
from public.tenant_memberships tm
|
||||||
|
where tm.tenant_id = $1
|
||||||
|
and tm.status = 'active'
|
||||||
|
and tm.role in ('tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent')
|
||||||
|
order by tm.user_id,
|
||||||
|
case tm.role
|
||||||
|
when 'sales' then 1
|
||||||
|
when 'agent' then 2
|
||||||
|
when 'tenant_operator' then 3
|
||||||
|
when 'teacher' then 4
|
||||||
|
else 9
|
||||||
|
end
|
||||||
|
),
|
||||||
|
commission_sources as (
|
||||||
|
select s.*,
|
||||||
|
coalesce(s.batch_rate, rm.commission_rate, settings.default_rate)::numeric(6,4) as commission_rate,
|
||||||
|
round(s.gross_amount_cents * coalesce(s.batch_rate, rm.commission_rate, settings.default_rate))::integer as commission_amount_cents
|
||||||
|
from sources s
|
||||||
|
cross join settings
|
||||||
|
left join referrer_members rm on rm.user_id = s.referrer_user_id
|
||||||
|
where s.gross_amount_cents > 0
|
||||||
|
),
|
||||||
|
lead_stats as (
|
||||||
|
select referrer_user_id,
|
||||||
|
count(*)::int as lead_count,
|
||||||
|
count(*) filter (where assigned_to_user_id is not null)::int as assigned_lead_count
|
||||||
|
from new_leads
|
||||||
|
group by referrer_user_id
|
||||||
|
),
|
||||||
|
converted_stats as (
|
||||||
|
select nl.referrer_user_id,
|
||||||
|
count(distinct nl.student_user_id)::int as converted_lead_count
|
||||||
|
from new_leads nl
|
||||||
|
where exists (
|
||||||
|
select 1
|
||||||
|
from commission_sources cs
|
||||||
|
where cs.referrer_user_id = nl.referrer_user_id
|
||||||
|
and cs.student_user_id = nl.student_user_id
|
||||||
|
and cs.source_paid_at >= nl.bound_at
|
||||||
|
)
|
||||||
|
group by nl.referrer_user_id
|
||||||
|
),
|
||||||
|
source_stats as (
|
||||||
|
select referrer_user_id,
|
||||||
|
count(*)::int as paid_source_count,
|
||||||
|
count(distinct student_user_id)::int as paid_lead_count,
|
||||||
|
coalesce(sum(gross_amount_cents), 0)::bigint as gross_amount_cents,
|
||||||
|
coalesce(sum(commission_amount_cents), 0)::bigint as commission_amount_cents
|
||||||
|
from commission_sources
|
||||||
|
group by referrer_user_id
|
||||||
|
),
|
||||||
|
paid_users as (
|
||||||
|
select lb.referrer_user_id, lb.student_user_id,
|
||||||
|
min(lb.bound_at) as bound_at,
|
||||||
|
min(cs.source_paid_at) as first_paid_at
|
||||||
|
from lead_base lb
|
||||||
|
join commission_sources cs on cs.referrer_user_id = lb.referrer_user_id
|
||||||
|
and cs.student_user_id = lb.student_user_id
|
||||||
|
and cs.source_paid_at >= lb.bound_at
|
||||||
|
group by lb.referrer_user_id, lb.student_user_id
|
||||||
|
),
|
||||||
|
paid_user_stats as (
|
||||||
|
select referrer_user_id,
|
||||||
|
count(*)::int as first_pay_user_count,
|
||||||
|
coalesce(sum(extract(epoch from (first_paid_at - bound_at)) / 3600), 0)::numeric as first_pay_hours_sum,
|
||||||
|
coalesce(avg(extract(epoch from (first_paid_at - bound_at)) / 3600), 0)::numeric as avg_first_pay_hours
|
||||||
|
from paid_users
|
||||||
|
group by referrer_user_id
|
||||||
|
),
|
||||||
|
track_stats as (
|
||||||
|
select ref_user_id as referrer_user_id,
|
||||||
|
count(*)::int as track_count,
|
||||||
|
count(*) filter (where event_type = 'share')::int as share_count,
|
||||||
|
count(*) filter (where event_type in ('scan', 'enter', 'register'))::int as intent_count
|
||||||
|
from public.referral_tracks
|
||||||
|
where tenant_id = $1
|
||||||
|
and ref_user_id is not null
|
||||||
|
and created_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
and created_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
and ($4::uuid is null or ref_user_id = $4::uuid)
|
||||||
|
group by ref_user_id
|
||||||
|
),
|
||||||
|
crm_stats as (
|
||||||
|
select lb.referrer_user_id,
|
||||||
|
count(*)::int as crm_queue_count,
|
||||||
|
count(*) filter (where q.status = 'sent')::int as crm_sent_count,
|
||||||
|
count(*) filter (where q.status in ('failed', 'discarded'))::int as crm_failed_count,
|
||||||
|
count(*) filter (where q.status in ('pending', 'processing', 'retrying'))::int as crm_pending_count
|
||||||
|
from public.crm_webhook_queue q
|
||||||
|
join lead_base lb on lb.id::text = q.lead_id
|
||||||
|
where q.tenant_id = $1
|
||||||
|
and q.created_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
and q.created_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
group by lb.referrer_user_id
|
||||||
|
),
|
||||||
|
followup_stats as (
|
||||||
|
select assigned_to_user_id as referrer_user_id,
|
||||||
|
count(*)::int as followup_count,
|
||||||
|
count(*) filter (where status in ('open', 'in_progress'))::int as open_followup_count,
|
||||||
|
count(*) filter (where status = 'done')::int as done_followup_count,
|
||||||
|
count(*) filter (where due_at < now() and status in ('open', 'in_progress'))::int as overdue_followup_count
|
||||||
|
from public.tenant_student_followups
|
||||||
|
where tenant_id = $1
|
||||||
|
and assigned_to_user_id is not null
|
||||||
|
and created_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
and created_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
and ($4::uuid is null or assigned_to_user_id = $4::uuid)
|
||||||
|
group by assigned_to_user_id
|
||||||
|
)
|
||||||
|
select r.user_id as "referrerUserId",
|
||||||
|
r.role,
|
||||||
|
r.username,
|
||||||
|
r.name,
|
||||||
|
r.phone,
|
||||||
|
r.invite_code as "inviteCode",
|
||||||
|
coalesce(ts.track_count, 0)::int as "trackCount",
|
||||||
|
coalesce(ts.share_count, 0)::int as "shareCount",
|
||||||
|
coalesce(ts.intent_count, 0)::int as "intentCount",
|
||||||
|
coalesce(ls.lead_count, 0)::int as "leadCount",
|
||||||
|
coalesce(ls.assigned_lead_count, 0)::int as "assignedLeadCount",
|
||||||
|
coalesce(cs.converted_lead_count, 0)::int as "convertedLeadCount",
|
||||||
|
coalesce(ss.paid_lead_count, 0)::int as "paidLeadCount",
|
||||||
|
coalesce(ss.paid_source_count, 0)::int as "paidSourceCount",
|
||||||
|
coalesce(ss.gross_amount_cents, 0)::text as "grossAmountCents",
|
||||||
|
coalesce(ss.commission_amount_cents, 0)::text as "commissionAmountCents",
|
||||||
|
coalesce(pus.first_pay_user_count, 0)::int as "firstPayUserCount",
|
||||||
|
coalesce(pus.first_pay_hours_sum, 0)::text as "firstPayHoursSum",
|
||||||
|
coalesce(pus.avg_first_pay_hours, 0)::text as "avgFirstPayHours",
|
||||||
|
coalesce(crm.crm_queue_count, 0)::int as "crmQueueCount",
|
||||||
|
coalesce(crm.crm_sent_count, 0)::int as "crmSentCount",
|
||||||
|
coalesce(crm.crm_failed_count, 0)::int as "crmFailedCount",
|
||||||
|
coalesce(crm.crm_pending_count, 0)::int as "crmPendingCount",
|
||||||
|
coalesce(fs.followup_count, 0)::int as "followupCount",
|
||||||
|
coalesce(fs.open_followup_count, 0)::int as "openFollowupCount",
|
||||||
|
coalesce(fs.done_followup_count, 0)::int as "doneFollowupCount",
|
||||||
|
coalesce(fs.overdue_followup_count, 0)::int as "overdueFollowupCount"
|
||||||
|
from referrers r
|
||||||
|
left join lead_stats ls on ls.referrer_user_id = r.user_id
|
||||||
|
left join converted_stats cs on cs.referrer_user_id = r.user_id
|
||||||
|
left join source_stats ss on ss.referrer_user_id = r.user_id
|
||||||
|
left join paid_user_stats pus on pus.referrer_user_id = r.user_id
|
||||||
|
left join track_stats ts on ts.referrer_user_id = r.user_id
|
||||||
|
left join crm_stats crm on crm.referrer_user_id = r.user_id
|
||||||
|
left join followup_stats fs on fs.referrer_user_id = r.user_id
|
||||||
|
order by coalesce(ss.gross_amount_cents, 0) desc,
|
||||||
|
coalesce(ls.lead_count, 0) desc,
|
||||||
|
r.name asc nulls last
|
||||||
|
limit $5
|
||||||
|
`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dailyRows = await query<Record<string, unknown>>(
|
||||||
|
`
|
||||||
|
with days as (
|
||||||
|
select generate_series($2::date, $3::date, interval '1 day')::date as day
|
||||||
|
),
|
||||||
|
settings as (
|
||||||
|
select coalesce(default_rate, 0.2000)::numeric(6,4) as default_rate
|
||||||
|
from public.tenant_commission_settings
|
||||||
|
where tenant_id = $1
|
||||||
|
union all
|
||||||
|
select 0.2000::numeric(6,4)
|
||||||
|
limit 1
|
||||||
|
),
|
||||||
|
lead_base as (
|
||||||
|
select rl.*
|
||||||
|
from public.referral_leads rl
|
||||||
|
where rl.tenant_id = $1
|
||||||
|
and rl.status = 'protected'
|
||||||
|
and rl.referrer_user_id is not null
|
||||||
|
and ($4::uuid is null or rl.referrer_user_id = $4::uuid)
|
||||||
|
),
|
||||||
|
referrer_members as (
|
||||||
|
select distinct on (tm.user_id)
|
||||||
|
tm.user_id, tm.commission_rate
|
||||||
|
from public.tenant_memberships tm
|
||||||
|
where tm.tenant_id = $1
|
||||||
|
and tm.status = 'active'
|
||||||
|
order by tm.user_id,
|
||||||
|
case tm.role
|
||||||
|
when 'sales' then 1
|
||||||
|
when 'agent' then 2
|
||||||
|
when 'tenant_operator' then 3
|
||||||
|
when 'teacher' then 4
|
||||||
|
else 9
|
||||||
|
end
|
||||||
|
),
|
||||||
|
lead_daily as (
|
||||||
|
select timezone('Asia/Shanghai', bound_at)::date as day,
|
||||||
|
count(*)::int as lead_count,
|
||||||
|
count(*) filter (where assigned_to_user_id is not null)::int as assigned_lead_count
|
||||||
|
from lead_base
|
||||||
|
where bound_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
and bound_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
group by timezone('Asia/Shanghai', bound_at)::date
|
||||||
|
),
|
||||||
|
track_daily as (
|
||||||
|
select timezone('Asia/Shanghai', created_at)::date as day,
|
||||||
|
count(*)::int as track_count
|
||||||
|
from public.referral_tracks
|
||||||
|
where tenant_id = $1
|
||||||
|
and ref_user_id is not null
|
||||||
|
and created_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
and created_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
and ($4::uuid is null or ref_user_id = $4::uuid)
|
||||||
|
group by timezone('Asia/Shanghai', created_at)::date
|
||||||
|
),
|
||||||
|
order_sources as (
|
||||||
|
select coalesce(o.paid_at, o.updated_at, o.created_at) as source_paid_at,
|
||||||
|
o.user_id as student_user_id,
|
||||||
|
lb.referrer_user_id,
|
||||||
|
o.amount_cents::integer as gross_amount_cents,
|
||||||
|
null::numeric(6,4) as batch_rate
|
||||||
|
from public.orders o
|
||||||
|
join lead_base lb on lb.tenant_id = o.tenant_id
|
||||||
|
and lb.student_user_id = o.user_id
|
||||||
|
and coalesce(o.paid_at, o.updated_at, o.created_at) >= lb.bound_at
|
||||||
|
where o.tenant_id = $1
|
||||||
|
and o.status = 'paid'
|
||||||
|
and o.user_id is not null
|
||||||
|
and coalesce(o.paid_at, o.updated_at, o.created_at) >= ($2::date::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
and coalesce(o.paid_at, o.updated_at, o.created_at) < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
),
|
||||||
|
code_sources as (
|
||||||
|
select ac.used_at as source_paid_at,
|
||||||
|
ac.used_by as student_user_id,
|
||||||
|
lb.referrer_user_id,
|
||||||
|
greatest(coalesce(ac.unit_price_cents, cb.default_unit_price_cents, 0), 0)::integer as gross_amount_cents,
|
||||||
|
cb.commission_rate as batch_rate
|
||||||
|
from public.activation_codes ac
|
||||||
|
left join public.code_batches cb on cb.tenant_id = ac.tenant_id and cb.id = ac.batch_id
|
||||||
|
join lead_base lb on lb.tenant_id = ac.tenant_id
|
||||||
|
and lb.student_user_id = ac.used_by
|
||||||
|
and ac.used_at >= lb.bound_at
|
||||||
|
where ac.tenant_id = $1
|
||||||
|
and ac.is_used is true
|
||||||
|
and ac.used_by is not null
|
||||||
|
and ac.used_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
and ac.used_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
),
|
||||||
|
commission_sources as (
|
||||||
|
select s.source_paid_at,
|
||||||
|
s.student_user_id,
|
||||||
|
s.referrer_user_id,
|
||||||
|
s.gross_amount_cents,
|
||||||
|
round(s.gross_amount_cents * coalesce(s.batch_rate, rm.commission_rate, settings.default_rate))::integer as commission_amount_cents
|
||||||
|
from (
|
||||||
|
select * from order_sources
|
||||||
|
union all
|
||||||
|
select * from code_sources
|
||||||
|
) s
|
||||||
|
cross join settings
|
||||||
|
left join referrer_members rm on rm.user_id = s.referrer_user_id
|
||||||
|
where s.gross_amount_cents > 0
|
||||||
|
),
|
||||||
|
source_daily as (
|
||||||
|
select timezone('Asia/Shanghai', source_paid_at)::date as day,
|
||||||
|
count(*)::int as paid_source_count,
|
||||||
|
count(distinct student_user_id)::int as paid_lead_count,
|
||||||
|
coalesce(sum(gross_amount_cents), 0)::bigint as gross_amount_cents,
|
||||||
|
coalesce(sum(commission_amount_cents), 0)::bigint as commission_amount_cents
|
||||||
|
from commission_sources
|
||||||
|
group by timezone('Asia/Shanghai', source_paid_at)::date
|
||||||
|
)
|
||||||
|
select d.day::text as date,
|
||||||
|
coalesce(td.track_count, 0)::int as "trackCount",
|
||||||
|
coalesce(ld.lead_count, 0)::int as "leadCount",
|
||||||
|
coalesce(ld.assigned_lead_count, 0)::int as "assignedLeadCount",
|
||||||
|
coalesce(sd.paid_lead_count, 0)::int as "paidLeadCount",
|
||||||
|
coalesce(sd.paid_source_count, 0)::int as "paidSourceCount",
|
||||||
|
coalesce(sd.gross_amount_cents, 0)::text as "grossAmountCents",
|
||||||
|
coalesce(sd.commission_amount_cents, 0)::text as "commissionAmountCents"
|
||||||
|
from days d
|
||||||
|
left join track_daily td on td.day = d.day
|
||||||
|
left join lead_daily ld on ld.day = d.day
|
||||||
|
left join source_daily sd on sd.day = d.day
|
||||||
|
order by d.day asc
|
||||||
|
`,
|
||||||
|
[auth.tenantId, startDate, endDate, scopedReferrerId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const recentUnconvertedRows = await query<Record<string, unknown>>(
|
||||||
|
`
|
||||||
|
with scoped_leads as (
|
||||||
|
select rl.*
|
||||||
|
from public.referral_leads rl
|
||||||
|
where rl.tenant_id = $1
|
||||||
|
and rl.status = 'protected'
|
||||||
|
and rl.referrer_user_id is not null
|
||||||
|
and rl.bound_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
and rl.bound_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
|
||||||
|
and ($4::uuid is null or rl.referrer_user_id = $4::uuid)
|
||||||
|
)
|
||||||
|
select rl.id,
|
||||||
|
rl.student_user_id as "studentUserId",
|
||||||
|
student.name as "studentName",
|
||||||
|
student.phone as "studentPhone",
|
||||||
|
rl.referrer_user_id as "referrerUserId",
|
||||||
|
referrer.name as "referrerName",
|
||||||
|
referrer.phone as "referrerPhone",
|
||||||
|
rl.assigned_to_user_id as "assignedToUserId",
|
||||||
|
assignee.name as "assignedToName",
|
||||||
|
rl.ref_code::text as "refCode",
|
||||||
|
rl.source,
|
||||||
|
rl.bound_at as "boundAt"
|
||||||
|
from scoped_leads rl
|
||||||
|
join public.platform_users student on student.id = rl.student_user_id
|
||||||
|
left join public.platform_users referrer on referrer.id = rl.referrer_user_id
|
||||||
|
left join public.platform_users assignee on assignee.id = rl.assigned_to_user_id
|
||||||
|
where not exists (
|
||||||
|
select 1
|
||||||
|
from public.orders o
|
||||||
|
where o.tenant_id = rl.tenant_id
|
||||||
|
and o.user_id = rl.student_user_id
|
||||||
|
and o.status = 'paid'
|
||||||
|
and coalesce(o.paid_at, o.updated_at, o.created_at) >= rl.bound_at
|
||||||
|
)
|
||||||
|
and not exists (
|
||||||
|
select 1
|
||||||
|
from public.activation_codes ac
|
||||||
|
where ac.tenant_id = rl.tenant_id
|
||||||
|
and ac.used_by = rl.student_user_id
|
||||||
|
and ac.is_used is true
|
||||||
|
and ac.used_at >= rl.bound_at
|
||||||
|
)
|
||||||
|
order by rl.bound_at desc
|
||||||
|
limit $5
|
||||||
|
`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
const byReferrer = byReferrerRows.map(row => {
|
||||||
|
const leadCount = intValue(row.leadCount);
|
||||||
|
const convertedLeadCount = intValue(row.convertedLeadCount);
|
||||||
|
const paidLeadCount = intValue(row.paidLeadCount);
|
||||||
|
const grossAmountCents = intValue(row.grossAmountCents);
|
||||||
|
const commissionAmountCents = intValue(row.commissionAmountCents);
|
||||||
|
return {
|
||||||
|
referrerUserId: String(row.referrerUserId),
|
||||||
|
role: String(row.role || ''),
|
||||||
|
username: row.username ? String(row.username) : null,
|
||||||
|
name: row.name ? String(row.name) : null,
|
||||||
|
phone: row.phone ? String(row.phone) : null,
|
||||||
|
inviteCode: row.inviteCode ? String(row.inviteCode) : null,
|
||||||
|
trackCount: intValue(row.trackCount),
|
||||||
|
shareCount: intValue(row.shareCount),
|
||||||
|
intentCount: intValue(row.intentCount),
|
||||||
|
leadCount,
|
||||||
|
assignedLeadCount: intValue(row.assignedLeadCount),
|
||||||
|
convertedLeadCount,
|
||||||
|
paidLeadCount,
|
||||||
|
paidSourceCount: intValue(row.paidSourceCount),
|
||||||
|
grossAmountCents,
|
||||||
|
grossAmount: centsToAmount(grossAmountCents),
|
||||||
|
commissionAmountCents,
|
||||||
|
commissionAmount: centsToAmount(commissionAmountCents),
|
||||||
|
firstPayUserCount: intValue(row.firstPayUserCount),
|
||||||
|
firstPayHoursSum: Number(row.firstPayHoursSum || 0),
|
||||||
|
avgFirstPayHours: Number(Number(row.avgFirstPayHours || 0).toFixed(2)),
|
||||||
|
crmQueueCount: intValue(row.crmQueueCount),
|
||||||
|
crmSentCount: intValue(row.crmSentCount),
|
||||||
|
crmFailedCount: intValue(row.crmFailedCount),
|
||||||
|
crmPendingCount: intValue(row.crmPendingCount),
|
||||||
|
followupCount: intValue(row.followupCount),
|
||||||
|
openFollowupCount: intValue(row.openFollowupCount),
|
||||||
|
doneFollowupCount: intValue(row.doneFollowupCount),
|
||||||
|
overdueFollowupCount: intValue(row.overdueFollowupCount),
|
||||||
|
conversionRate: ratio(convertedLeadCount, leadCount),
|
||||||
|
paidLeadRate: ratio(paidLeadCount, leadCount),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryBase = byReferrer.reduce((acc, item) => {
|
||||||
|
acc.trackCount += item.trackCount;
|
||||||
|
acc.shareCount += item.shareCount;
|
||||||
|
acc.intentCount += item.intentCount;
|
||||||
|
acc.leadCount += item.leadCount;
|
||||||
|
acc.assignedLeadCount += item.assignedLeadCount;
|
||||||
|
acc.convertedLeadCount += item.convertedLeadCount;
|
||||||
|
acc.paidLeadCount += item.paidLeadCount;
|
||||||
|
acc.paidSourceCount += item.paidSourceCount;
|
||||||
|
acc.grossAmountCents += item.grossAmountCents;
|
||||||
|
acc.commissionAmountCents += item.commissionAmountCents;
|
||||||
|
acc.firstPayUserCount += item.firstPayUserCount;
|
||||||
|
acc.firstPayHoursSum += item.firstPayHoursSum;
|
||||||
|
acc.crmQueueCount += item.crmQueueCount;
|
||||||
|
acc.crmSentCount += item.crmSentCount;
|
||||||
|
acc.crmFailedCount += item.crmFailedCount;
|
||||||
|
acc.crmPendingCount += item.crmPendingCount;
|
||||||
|
acc.followupCount += item.followupCount;
|
||||||
|
acc.openFollowupCount += item.openFollowupCount;
|
||||||
|
acc.doneFollowupCount += item.doneFollowupCount;
|
||||||
|
acc.overdueFollowupCount += item.overdueFollowupCount;
|
||||||
|
return acc;
|
||||||
|
}, {
|
||||||
|
trackCount: 0,
|
||||||
|
shareCount: 0,
|
||||||
|
intentCount: 0,
|
||||||
|
leadCount: 0,
|
||||||
|
assignedLeadCount: 0,
|
||||||
|
convertedLeadCount: 0,
|
||||||
|
paidLeadCount: 0,
|
||||||
|
paidSourceCount: 0,
|
||||||
|
grossAmountCents: 0,
|
||||||
|
commissionAmountCents: 0,
|
||||||
|
firstPayUserCount: 0,
|
||||||
|
firstPayHoursSum: 0,
|
||||||
|
crmQueueCount: 0,
|
||||||
|
crmSentCount: 0,
|
||||||
|
crmFailedCount: 0,
|
||||||
|
crmPendingCount: 0,
|
||||||
|
followupCount: 0,
|
||||||
|
openFollowupCount: 0,
|
||||||
|
doneFollowupCount: 0,
|
||||||
|
overdueFollowupCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
tenantId: auth.tenantId,
|
||||||
|
range: { startDate, endDate, days, referrerUserId: scopedReferrerId },
|
||||||
|
summary: {
|
||||||
|
...summaryBase,
|
||||||
|
grossAmount: centsToAmount(summaryBase.grossAmountCents),
|
||||||
|
commissionAmount: centsToAmount(summaryBase.commissionAmountCents),
|
||||||
|
conversionRate: ratio(summaryBase.convertedLeadCount, summaryBase.leadCount),
|
||||||
|
paidLeadRate: ratio(summaryBase.paidLeadCount, summaryBase.leadCount),
|
||||||
|
avgFirstPayHours: summaryBase.firstPayUserCount
|
||||||
|
? Number((summaryBase.firstPayHoursSum / summaryBase.firstPayUserCount).toFixed(2))
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
|
funnel: [
|
||||||
|
{ key: 'tracks', label: '触达/扫码', count: summaryBase.trackCount },
|
||||||
|
{ key: 'leads', label: '新增客资', count: summaryBase.leadCount },
|
||||||
|
{ key: 'assigned', label: '已分配跟进', count: summaryBase.assignedLeadCount },
|
||||||
|
{ key: 'converted', label: '有效成交客资', count: summaryBase.convertedLeadCount },
|
||||||
|
{ key: 'paid_sources', label: '成交来源', count: summaryBase.paidSourceCount },
|
||||||
|
],
|
||||||
|
byReferrer,
|
||||||
|
dailyTrend: dailyRows.map(row => ({
|
||||||
|
date: String(row.date),
|
||||||
|
trackCount: intValue(row.trackCount),
|
||||||
|
leadCount: intValue(row.leadCount),
|
||||||
|
assignedLeadCount: intValue(row.assignedLeadCount),
|
||||||
|
paidLeadCount: intValue(row.paidLeadCount),
|
||||||
|
paidSourceCount: intValue(row.paidSourceCount),
|
||||||
|
grossAmountCents: intValue(row.grossAmountCents),
|
||||||
|
commissionAmountCents: intValue(row.commissionAmountCents),
|
||||||
|
})),
|
||||||
|
recentUnconvertedLeads: recentUnconvertedRows.map(row => ({
|
||||||
|
id: String(row.id),
|
||||||
|
studentUserId: String(row.studentUserId),
|
||||||
|
studentName: row.studentName ? String(row.studentName) : null,
|
||||||
|
studentPhone: row.studentPhone ? String(row.studentPhone) : null,
|
||||||
|
referrerUserId: String(row.referrerUserId),
|
||||||
|
referrerName: row.referrerName ? String(row.referrerName) : null,
|
||||||
|
referrerPhone: row.referrerPhone ? String(row.referrerPhone) : null,
|
||||||
|
assignedToUserId: row.assignedToUserId ? String(row.assignedToUserId) : null,
|
||||||
|
assignedToName: row.assignedToName ? String(row.assignedToName) : null,
|
||||||
|
refCode: row.refCode ? String(row.refCode) : null,
|
||||||
|
source: row.source ? String(row.source) : null,
|
||||||
|
boundAt: row.boundAt,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function referralSalesClientsRoute(ctx: RequestContext) {
|
export async function referralSalesClientsRoute(ctx: RequestContext) {
|
||||||
const auth = await requireTenantAdmin(ctx);
|
const auth = await requireTenantAdmin(ctx);
|
||||||
if (!canViewSelfReferral(auth)) {
|
if (!canViewSelfReferral(auth)) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
loadPointExchangeItems,
|
loadPointExchangeItems,
|
||||||
loadPointExchangeOrders,
|
loadPointExchangeOrders,
|
||||||
loadPointsRiskReport,
|
loadPointsRiskReport,
|
||||||
|
loadReferralConversionReport,
|
||||||
loadTenantMembers,
|
loadTenantMembers,
|
||||||
loadUserNotifications,
|
loadUserNotifications,
|
||||||
updateCommissionSettings,
|
updateCommissionSettings,
|
||||||
@@ -53,6 +54,7 @@ import {
|
|||||||
type PointActivityTaskItem,
|
type PointActivityTaskItem,
|
||||||
type PointExchangeItem,
|
type PointExchangeItem,
|
||||||
type PointsRiskReport,
|
type PointsRiskReport,
|
||||||
|
type ReferralConversionReport,
|
||||||
type TenantMemberItem,
|
type TenantMemberItem,
|
||||||
type UserNotificationAdminItem,
|
type UserNotificationAdminItem,
|
||||||
} from '@/services/tenantAdmin';
|
} from '@/services/tenantAdmin';
|
||||||
@@ -305,6 +307,7 @@ export default function TenantMarketingPage() {
|
|||||||
const [crmLogs, setCrmLogs] = useState<Record<string, CrmQueueLogItem[]>>({});
|
const [crmLogs, setCrmLogs] = useState<Record<string, CrmQueueLogItem[]>>({});
|
||||||
const [commissionSettings, setCommissionSettings] = useState<CommissionSettingsItem | null>(null);
|
const [commissionSettings, setCommissionSettings] = useState<CommissionSettingsItem | null>(null);
|
||||||
const [commission, setCommission] = useState<CommissionSummaryItem | null>(null);
|
const [commission, setCommission] = useState<CommissionSummaryItem | null>(null);
|
||||||
|
const [conversionReport, setConversionReport] = useState<ReferralConversionReport | null>(null);
|
||||||
const [commissionOrders, setCommissionOrders] = useState<CommissionOrderItem[]>([]);
|
const [commissionOrders, setCommissionOrders] = useState<CommissionOrderItem[]>([]);
|
||||||
const [settlements, setSettlements] = useState<CommissionSettlementItem[]>([]);
|
const [settlements, setSettlements] = useState<CommissionSettlementItem[]>([]);
|
||||||
const [settlementProofs, setSettlementProofs] = useState<Record<string, CommissionSettlementProofItem[]>>({});
|
const [settlementProofs, setSettlementProofs] = useState<Record<string, CommissionSettlementProofItem[]>>({});
|
||||||
@@ -380,6 +383,7 @@ export default function TenantMarketingPage() {
|
|||||||
crmDeadLettersPayload,
|
crmDeadLettersPayload,
|
||||||
commissionSettingsPayload,
|
commissionSettingsPayload,
|
||||||
commissionPayload,
|
commissionPayload,
|
||||||
|
conversionReportPayload,
|
||||||
orderPayload,
|
orderPayload,
|
||||||
settlementPayload,
|
settlementPayload,
|
||||||
memberPayload,
|
memberPayload,
|
||||||
@@ -410,6 +414,7 @@ export default function TenantMarketingPage() {
|
|||||||
loadCrmDeadLetters({ limit: 20 }).catch(() => ({ items: [], summary: {} })),
|
loadCrmDeadLetters({ limit: 20 }).catch(() => ({ items: [], summary: {} })),
|
||||||
loadCommissionSettings().catch(() => ({ item: null })),
|
loadCommissionSettings().catch(() => ({ item: null })),
|
||||||
loadCommissionSummary(period).catch(() => ({ item: null })),
|
loadCommissionSummary(period).catch(() => ({ item: null })),
|
||||||
|
loadReferralConversionReport({ ...period, limit: 20 }).catch(() => ({ item: null })),
|
||||||
loadCommissionOrders({ ...period, limit: 20 }).catch(() => ({ items: [] })),
|
loadCommissionOrders({ ...period, limit: 20 }).catch(() => ({ items: [] })),
|
||||||
loadCommissionSettlements({ limit: 20 }).catch(() => ({ items: [] })),
|
loadCommissionSettlements({ limit: 20 }).catch(() => ({ items: [] })),
|
||||||
loadTenantMembers({ limit: 100 }).catch(() => ({ items: [] })),
|
loadTenantMembers({ limit: 100 }).catch(() => ({ items: [] })),
|
||||||
@@ -438,6 +443,7 @@ export default function TenantMarketingPage() {
|
|||||||
setCrmDeadLetters(crmDeadLettersPayload || {});
|
setCrmDeadLetters(crmDeadLettersPayload || {});
|
||||||
setCommissionSettings(nextSettings);
|
setCommissionSettings(nextSettings);
|
||||||
setCommission(commissionPayload.item || null);
|
setCommission(commissionPayload.item || null);
|
||||||
|
setConversionReport(conversionReportPayload.item || null);
|
||||||
setCommissionOrders(orderPayload.items || []);
|
setCommissionOrders(orderPayload.items || []);
|
||||||
setSettlements(settlementPayload.items || []);
|
setSettlements(settlementPayload.items || []);
|
||||||
setMembers(memberPayload.items || []);
|
setMembers(memberPayload.items || []);
|
||||||
@@ -882,12 +888,14 @@ export default function TenantMarketingPage() {
|
|||||||
setBusy('commission');
|
setBusy('commission');
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const [summaryPayload, orderPayload, settlementPayload] = await Promise.all([
|
const [summaryPayload, conversionPayload, orderPayload, settlementPayload] = await Promise.all([
|
||||||
loadCommissionSummary(period),
|
loadCommissionSummary(period),
|
||||||
|
loadReferralConversionReport({ ...period, limit: 30 }),
|
||||||
loadCommissionOrders({ ...period, limit: 30 }),
|
loadCommissionOrders({ ...period, limit: 30 }),
|
||||||
loadCommissionSettlements({ limit: 30 }),
|
loadCommissionSettlements({ limit: 30 }),
|
||||||
]);
|
]);
|
||||||
setCommission(summaryPayload.item || null);
|
setCommission(summaryPayload.item || null);
|
||||||
|
setConversionReport(conversionPayload.item || null);
|
||||||
setCommissionOrders(orderPayload.items || []);
|
setCommissionOrders(orderPayload.items || []);
|
||||||
setSettlements(settlementPayload.items || []);
|
setSettlements(settlementPayload.items || []);
|
||||||
} catch (nextError) {
|
} catch (nextError) {
|
||||||
@@ -1544,6 +1552,67 @@ export default function TenantMarketingPage() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className='admin-section'>
|
||||||
|
<Text className='admin-section-title'>销售/代理转化</Text>
|
||||||
|
<View className='admin-grid'>
|
||||||
|
<View className='admin-metric'><Text className='admin-metric-label'>新增客资</Text><Text className='admin-metric-value'>{String(conversionReport?.summary?.leadCount || 0)}</Text></View>
|
||||||
|
<View className='admin-metric'><Text className='admin-metric-label'>有效成交</Text><Text className='admin-metric-value'>{String(conversionReport?.summary?.convertedLeadCount || 0)}</Text></View>
|
||||||
|
<View className='admin-metric'><Text className='admin-metric-label'>转化率</Text><Text className='admin-metric-value'>{percentLabel(conversionReport?.summary?.conversionRate)}</Text></View>
|
||||||
|
<View className='admin-metric'><Text className='admin-metric-label'>成交金额</Text><Text className='admin-metric-value'>{money(conversionReport?.summary?.grossAmountCents)}</Text></View>
|
||||||
|
</View>
|
||||||
|
<View className='admin-grid'>
|
||||||
|
<View className='admin-metric'><Text className='admin-metric-label'>预计佣金</Text><Text className='admin-metric-value'>{money(conversionReport?.summary?.commissionAmountCents)}</Text></View>
|
||||||
|
<View className='admin-metric'><Text className='admin-metric-label'>CRM 失败</Text><Text className='admin-metric-value'>{String(conversionReport?.summary?.crmFailedCount || 0)}</Text></View>
|
||||||
|
<View className='admin-metric'><Text className='admin-metric-label'>跟进积压</Text><Text className='admin-metric-value'>{String(conversionReport?.summary?.openFollowupCount || 0)}</Text></View>
|
||||||
|
<View className='admin-metric'><Text className='admin-metric-label'>首付均时</Text><Text className='admin-metric-value'>{String(conversionReport?.summary?.avgFirstPayHours || 0)}h</Text></View>
|
||||||
|
</View>
|
||||||
|
<View className='admin-list compact'>
|
||||||
|
{(conversionReport?.funnel || []).map(item => (
|
||||||
|
<View className='admin-row' key={String(item.key || item.label)}>
|
||||||
|
<Text className='admin-row-main'>{String(item.label || item.key || '漏斗')}</Text>
|
||||||
|
<Text className='admin-row-meta'>{String(item.count || 0)}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View className='admin-list'>
|
||||||
|
{(conversionReport?.byReferrer || []).slice(0, 8).map(item => (
|
||||||
|
<View className='admin-row' key={item.referrerUserId}>
|
||||||
|
<Text className='admin-row-main'>{item.name || item.phone || item.username || item.referrerUserId} · {money(item.grossAmountCents)}</Text>
|
||||||
|
<Text className='admin-row-meta'>
|
||||||
|
{item.role || 'member'} · 客资 {String(item.leadCount || 0)} · 成交 {String(item.convertedLeadCount || 0)} · 转化 {percentLabel(item.conversionRate)} · 佣金 {money(item.commissionAmountCents)}
|
||||||
|
</Text>
|
||||||
|
<Text className='admin-row-meta'>
|
||||||
|
CRM 失败 {String(item.crmFailedCount || 0)} · 待跟进 {String(item.openFollowupCount || 0)} · 超期 {String(item.overdueFollowupCount || 0)} · 首付均时 {String(item.avgFirstPayHours || 0)}h
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{(conversionReport?.dailyTrend || []).length ? (
|
||||||
|
<View className='admin-list compact'>
|
||||||
|
{(conversionReport?.dailyTrend || []).slice(-7).map(item => (
|
||||||
|
<View className='admin-row' key={String(item.date)}>
|
||||||
|
<Text className='admin-row-main'>{String(item.date || '-')}</Text>
|
||||||
|
<Text className='admin-row-meta'>客资 {String(item.leadCount || 0)} · 成交来源 {String(item.paidSourceCount || 0)} · 金额 {money(item.grossAmountCents)}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{(conversionReport?.recentUnconvertedLeads || []).length ? (
|
||||||
|
<View className='admin-list'>
|
||||||
|
<Text className='admin-section-title'>近期未成交客资</Text>
|
||||||
|
{(conversionReport?.recentUnconvertedLeads || []).slice(0, 6).map(item => (
|
||||||
|
<View className='admin-row' key={String(item.id)}>
|
||||||
|
<Text className='admin-row-main'>{item.studentName || item.studentPhone || item.studentUserId || '学生'} · {item.referrerName || item.referrerPhone || item.referrerUserId || '归属人'}</Text>
|
||||||
|
<Text className='admin-row-meta'>跟进 {item.assignedToName || item.assignedToUserId || '-'} · 来源 {item.source || '-'} · {shortDate(item.boundAt)}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{!(conversionReport?.byReferrer?.length || conversionReport?.recentUnconvertedLeads?.length) ? (
|
||||||
|
<View className='admin-empty'>当前筛选范围暂无转化数据,或当前角色只能查看本人客资。</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className='admin-section'>
|
<View className='admin-section'>
|
||||||
<Text className='admin-section-title'>分佣订单明细</Text>
|
<Text className='admin-section-title'>分佣订单明细</Text>
|
||||||
<View className='admin-list'>
|
<View className='admin-list'>
|
||||||
|
|||||||
@@ -840,6 +840,86 @@ export interface CrmQueueLogItem {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReferralConversionReferrerItem {
|
||||||
|
referrerUserId: string;
|
||||||
|
role?: string;
|
||||||
|
username?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
inviteCode?: string | null;
|
||||||
|
trackCount?: number;
|
||||||
|
shareCount?: number;
|
||||||
|
intentCount?: number;
|
||||||
|
leadCount?: number;
|
||||||
|
assignedLeadCount?: number;
|
||||||
|
convertedLeadCount?: number;
|
||||||
|
paidLeadCount?: number;
|
||||||
|
paidSourceCount?: number;
|
||||||
|
grossAmountCents?: number;
|
||||||
|
grossAmount?: number;
|
||||||
|
commissionAmountCents?: number;
|
||||||
|
commissionAmount?: number;
|
||||||
|
firstPayUserCount?: number;
|
||||||
|
avgFirstPayHours?: number;
|
||||||
|
crmQueueCount?: number;
|
||||||
|
crmSentCount?: number;
|
||||||
|
crmFailedCount?: number;
|
||||||
|
crmPendingCount?: number;
|
||||||
|
followupCount?: number;
|
||||||
|
openFollowupCount?: number;
|
||||||
|
doneFollowupCount?: number;
|
||||||
|
overdueFollowupCount?: number;
|
||||||
|
conversionRate?: number;
|
||||||
|
paidLeadRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReferralConversionReport {
|
||||||
|
tenantId?: string;
|
||||||
|
range?: {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
days?: number;
|
||||||
|
referrerUserId?: string | null;
|
||||||
|
};
|
||||||
|
summary?: Partial<ReferralConversionReferrerItem> & {
|
||||||
|
grossAmountCents?: number;
|
||||||
|
commissionAmountCents?: number;
|
||||||
|
grossAmount?: number;
|
||||||
|
commissionAmount?: number;
|
||||||
|
firstPayHoursSum?: number;
|
||||||
|
};
|
||||||
|
funnel?: Array<{
|
||||||
|
key?: string;
|
||||||
|
label?: string;
|
||||||
|
count?: number;
|
||||||
|
}>;
|
||||||
|
byReferrer?: ReferralConversionReferrerItem[];
|
||||||
|
dailyTrend?: Array<{
|
||||||
|
date?: string;
|
||||||
|
trackCount?: number;
|
||||||
|
leadCount?: number;
|
||||||
|
assignedLeadCount?: number;
|
||||||
|
paidLeadCount?: number;
|
||||||
|
paidSourceCount?: number;
|
||||||
|
grossAmountCents?: number;
|
||||||
|
commissionAmountCents?: number;
|
||||||
|
}>;
|
||||||
|
recentUnconvertedLeads?: Array<{
|
||||||
|
id?: string;
|
||||||
|
studentUserId?: string;
|
||||||
|
studentName?: string | null;
|
||||||
|
studentPhone?: string | null;
|
||||||
|
referrerUserId?: string;
|
||||||
|
referrerName?: string | null;
|
||||||
|
referrerPhone?: string | null;
|
||||||
|
assignedToUserId?: string | null;
|
||||||
|
assignedToName?: string | null;
|
||||||
|
refCode?: string | null;
|
||||||
|
source?: string | null;
|
||||||
|
boundAt?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CommissionSettingsItem {
|
export interface CommissionSettingsItem {
|
||||||
defaultRate?: number | string;
|
defaultRate?: number | string;
|
||||||
minSettlementCents?: number;
|
minSettlementCents?: number;
|
||||||
@@ -1699,6 +1779,17 @@ export async function loadCommissionSummary(query: { startDate?: string; endDate
|
|||||||
return apiRequest<{ item?: CommissionSummaryItem }>('/api/commission/summary', { query });
|
return apiRequest<{ item?: CommissionSummaryItem }>('/api/commission/summary', { query });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadReferralConversionReport(query: {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
referrerUserId?: string;
|
||||||
|
limit?: number;
|
||||||
|
} = {}) {
|
||||||
|
return apiRequest<{ item?: ReferralConversionReport }>('/api/referral/conversion-report', {
|
||||||
|
query: { ...query, limit: query.limit || 20 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadCommissionOrders(query: { startDate?: string; endDate?: string; referrerUserId?: string; limit?: number } = {}) {
|
export async function loadCommissionOrders(query: { startDate?: string; endDate?: string; referrerUserId?: string; limit?: number } = {}) {
|
||||||
return apiRequest<{ items?: CommissionOrderItem[]; scope?: Record<string, unknown> }>('/api/commission/orders', {
|
return apiRequest<{ items?: CommissionOrderItem[]; scope?: Record<string, unknown> }>('/api/commission/orders', {
|
||||||
query: { ...query, limit: query.limit || 50 },
|
query: { ...query, limit: query.limit || 50 },
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
- 平台侧可以管理租户、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费、逾期催缴、用量台账、月度用量自动采集和用量超额账单。
|
- 平台侧可以管理租户、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费、逾期催缴、用量台账、月度用量自动采集和用量超额账单。
|
||||||
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。
|
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。
|
||||||
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、男女预设头像、站内通知、勋章、排行榜接口(租户默认关闭)、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API;学生头像不支持上传或第三方头像落库,学生写入口会拒绝头像 URL;签到、积分阈值、反馈解决和积分活动可触发自动勋章发放,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知;租户后台已具备反馈运营聚合报表和积分风控只读报表。
|
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、男女预设头像、站内通知、勋章、排行榜接口(租户默认关闭)、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API;学生头像不支持上传或第三方头像落库,学生写入口会拒绝头像 URL;签到、积分阈值、反馈解决和积分活动可触发自动勋章发放,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知;租户后台已具备反馈运营聚合报表和积分风控只读报表。
|
||||||
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置、入队、worker 推送、失败死信运营、手动重试/忽略和分佣结算基础闭环。
|
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、销售/代理转化报表、CRM 配置、入队、worker 推送、失败死信运营、手动重试/忽略和分佣结算基础闭环。
|
||||||
- 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。
|
- 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。
|
||||||
|
|
||||||
因此,后端现在已经具备进入 Taro 前端第一阶段联调的基础。需要注意的是,它还不是完整生产交付状态,真实云端鉴权、对象存储生产安全、支付/短信/OAuth 生产账号、真实数据 dry-run 迁移仍需要继续补齐或联调;导入后复检、模板下载、字段映射 API 和导入任务详情已可联调,Taro 租户内容页已接入上传/粘贴预览、字段别名覆盖、同步/异步执行、异步轮询和复检详情第一版,租户营销中心已接入 CRM 配置/队列、分佣结算、积分任务/兑换和积分风控只读摘要第一版。
|
因此,后端现在已经具备进入 Taro 前端第一阶段联调的基础。需要注意的是,它还不是完整生产交付状态,真实云端鉴权、对象存储生产安全、支付/短信/OAuth 生产账号、真实数据 dry-run 迁移仍需要继续补齐或联调;导入后复检、模板下载、字段映射 API 和导入任务详情已可联调,Taro 租户内容页已接入上传/粘贴预览、字段别名覆盖、同步/异步执行、异步轮询和复检详情第一版,租户营销中心已接入 CRM 配置/队列、分佣结算、积分任务/兑换和积分风控只读摘要第一版。
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、访问审计、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 可生成可信 `content_assets` 并走签名下载/预览 | CDN 防盗链、真实 AV/内容安全服务联调、转码级水印、资料前端操作体验 |
|
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、访问审计、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 可生成可信 `content_assets` 并走签名下载/预览 | CDN 防盗链、真实 AV/内容安全服务联调、转码级水印、资料前端操作体验 |
|
||||||
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放、资金对账批次/明细/异常查询 API、微信/支付宝官方账单下载 worker、对账差错工单和事件轨迹 | 财务复核报表、异常订单运营台和真实生产账单格式验收 |
|
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放、资金对账批次/明细/异常查询 API、微信/支付宝官方账单下载 worker、对账差错工单和事件轨迹 | 财务复核报表、异常订单运营台和真实生产账单格式验收 |
|
||||||
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
|
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
|
||||||
| 销售/代理/CRM | 可联调 | 邀请码、首绑保护、团队关系、销售统计、CRM 配置/队列、死信失败池、脱敏日志、手动重试/忽略、钉钉/飞书/企微 worker、分佣规则、成员分佣比例、订单/激活码归因、结算生成、审核和打款状态;Taro 营销中心已接第一版操作台 | 小程序码真实生成、CRM 富卡片模板、外部失败告警升级、结算导出、真实打款、凭证、财务复核和销售转化看板 |
|
| 销售/代理/CRM | 可联调 | 邀请码、首绑保护、团队关系、销售统计、销售/代理转化报表、CRM 配置/队列、死信失败池、脱敏日志、手动重试/忽略、钉钉/飞书/企微 worker、分佣规则、成员分佣比例、订单/激活码归因、结算生成、审核和打款状态;Taro 营销中心已接第一版操作台 | 小程序码真实生成、CRM 富卡片模板、外部失败告警升级、真实打款、发票、转化预聚合、凭证和财务复核 |
|
||||||
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 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、报告列表/详情、Markdown/HTML 导出和 Taro 学生端基础页 | 真实 AI provider、prompt 版本管理、租户后台配置、报告 PDF worker 渲染和人工复核流程 |
|
| AI 择校推荐 | 可联调 | `ai_recommendation_reports`、SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON、报告列表/详情、Markdown/HTML 导出和 Taro 学生端基础页 | 真实 AI provider、prompt 版本管理、租户后台配置、报告 PDF worker 渲染和人工复核流程 |
|
||||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口和男女预设头像选择第一版;平台后台已接关键写操作、租户详情、账务资料编辑、平台员工列表/创建/编辑/禁用恢复、平台审计查询和 CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅账单候选预览/dry-run/批量生成、自动计费生成结果查看、逾期预览、催缴记录和催缴外部通知摘要第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注、跟进任务、学习督导预览/生成和保存每日规则第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款、积分任务/兑换和积分风控摘要第一版 | 长题干/公式图片混排体验、更细数据范围 UI、学习督导触达联动、平台审计告警升级策略、平台催缴通知配置操作台细节、小程序兼容验证和端到端测试 |
|
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口和男女预设头像选择第一版;平台后台已接关键写操作、租户详情、账务资料编辑、平台员工列表/创建/编辑/禁用恢复、平台审计查询和 CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅账单候选预览/dry-run/批量生成、自动计费生成结果查看、逾期预览、催缴记录和催缴外部通知摘要第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注、跟进任务、学习督导预览/生成和保存每日规则第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款、积分任务/兑换和积分风控摘要第一版 | 长题干/公式图片混排体验、更细数据范围 UI、学习督导触达联动、平台审计告警升级策略、平台催缴通知配置操作台细节、小程序兼容验证和端到端测试 |
|
||||||
|
|
||||||
@@ -95,8 +95,8 @@
|
|||||||
|
|
||||||
- 租户自定义角色模板基础 API、Taro 权限配置 UI、成员绑定模板和工作台权限驱动入口第一版已完成;继续补班级/教师/学生组合范围 UI、成员批量运营和更完整平台级审计报表。
|
- 租户自定义角色模板基础 API、Taro 权限配置 UI、成员绑定模板和工作台权限驱动入口第一版已完成;继续补班级/教师/学生组合范围 UI、成员批量运营和更完整平台级审计报表。
|
||||||
- 三套默认主题、租户主题预览、Logo/图标/分享图配置。
|
- 三套默认主题、租户主题预览、Logo/图标/分享图配置。
|
||||||
- CRM worker:钉钉、飞书、企微机器人发送、签名、失败重试、死信失败池、脱敏日志、手动重试/忽略和审计已落地;Taro 营销中心已能配置 CRM、查看队列并处理失败任务;继续补富卡片、外部失败告警升级和销售转化看板。
|
- CRM worker:钉钉、飞书、企微机器人发送、签名、失败重试、死信失败池、脱敏日志、手动重试/忽略和审计已落地;Taro 营销中心已能配置 CRM、查看队列并处理失败任务,销售/代理转化看板第一版已接入;继续补富卡片、外部失败告警升级、转化预聚合和更细团队范围。
|
||||||
- 销售/代理分佣基础闭环已接 Taro 第一版;继续补销售团队看板、客资跟进效果、结算导出、真实打款、凭证和财务复核。
|
- 销售/代理分佣基础闭环已接 Taro 第一版;继续补销售团队预聚合、客资跟进效果归因、真实打款 provider、发票、凭证批量上传和财务复核。
|
||||||
- AI 择校推荐:`local_rules` JSON 报告、本人报告 Markdown/HTML 导出地基已完成;继续补真实 AI provider、prompt 版本、租户后台配置、PDF worker 报告生成和人工复核。
|
- AI 择校推荐:`local_rules` JSON 报告、本人报告 Markdown/HTML 导出地基已完成;继续补真实 AI provider、prompt 版本、租户后台配置、PDF worker 报告生成和人工复核。
|
||||||
- 性能压测、慢 SQL 审查、备份恢复演练、灰度发布和回滚预案。
|
- 性能压测、慢 SQL 审查、备份恢复演练、灰度发布和回滚预案。
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
## 当前可进入的前端工作
|
## 当前可进入的前端工作
|
||||||
|
|
||||||
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
||||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务、跟进看板、学习督导候选预览/生成、督导规则保存和 CRM 批量推送第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表、积分任务/兑换、积分风控只读摘要和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务、跟进看板、学习督导候选预览/生成、督导规则保存和 CRM 批量推送第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、CRM 死信处理、销售/代理转化看板、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表、积分任务/兑换、积分风控只读摘要和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
||||||
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权、平台员工;启动时应先接 `GET /api/platform-admin/permissions` 获取 `effective` 权限用于菜单和按钮可见性;租户管理页已接租户详情、账务资料编辑和最近平台审计,平台员工页已接员工列表、搜索、创建/编辑、权限点勾选、禁用和恢复,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV,并可查看开放审计告警、确认或解决告警,也能查看审计告警外部通知渠道、催缴外部通知渠道和最近发送事件摘要;账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、用量超额账单候选预览/dry-run/生成/自动开票 worker、逾期预览、内部催缴生成、催缴记录查看和用量台账查看。平台用量由 `platform-usage` worker 从后端权威表采集,超额费用由后端按套餐额度统一计算,前端不要自行统计答题记录、资源大小、订单或视频播放后计算服务费。
|
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权、平台员工;启动时应先接 `GET /api/platform-admin/permissions` 获取 `effective` 权限用于菜单和按钮可见性;租户管理页已接租户详情、账务资料编辑和最近平台审计,平台员工页已接员工列表、搜索、创建/编辑、权限点勾选、禁用和恢复,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV,并可查看开放审计告警、确认或解决告警,也能查看审计告警外部通知渠道、催缴外部通知渠道和最近发送事件摘要;账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、用量超额账单候选预览/dry-run/生成/自动开票 worker、逾期预览、内部催缴生成、催缴记录查看和用量台账查看。平台用量由 `platform-usage` worker 从后端权威表采集,超额费用由后端按套餐额度统一计算,前端不要自行统计答题记录、资源大小、订单或视频播放后计算服务费。
|
||||||
- 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习/学习概览、商城收银台、订单详情和售后入口已经有第一版页面。
|
- 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习/学习概览、商城收银台、订单详情和售后入口已经有第一版页面。
|
||||||
- 可以按新后端主模型接入内容导航:
|
- 可以按新后端主模型接入内容导航:
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
- 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。
|
- 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。
|
||||||
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览、动态水印上下文、资源复检 worker、内置安全扫描、外部 HTTP scanner 接入层和租户后台媒体运营报表;Taro 学生资料页已接短期签名、水印 traceId 展示和强制水印容器第一版;CDN 防盗链、转码级视频水印和真实 AV/内容安全服务联调还要补。
|
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览、动态水印上下文、资源复检 worker、内置安全扫描、外部 HTTP scanner 接入层和租户后台媒体运营报表;Taro 学生资料页已接短期签名、水印 traceId 展示和强制水印容器第一版;CDN 防盗链、转码级视频水印和真实 AV/内容安全服务联调还要补。
|
||||||
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API、导入任务详情和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、下载模板、执行后端预览、编辑本次字段别名、同步/异步提交导入、轮询异步 job、查看问题行并触发/查看复检;后续还要补真实数据 dry-run 验收和更完整的目标入口/集合选择。
|
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API、导入任务详情和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、下载模板、执行后端预览、编辑本次字段别名、同步/异步提交导入、轮询异步 job、查看问题行并触发/查看复检;后续还要补真实数据 dry-run 验收和更完整的目标入口/集合选择。
|
||||||
- 数据看板、分佣结算、财务运营、勋章手动发放、签到/积分/反馈/积分活动/练习/单词/模考自动发放、积分任务/兑换第一阶段、积分风控只读报表、用户站内通知第一版和主题模板发布基础 API 已可联调;Taro 学生个人中心已接积分任务/兑换/积分明细第一版,租户营销中心已接积分任务/兑换配置、记录查看和风控摘要第一版;外部微信订阅消息/短信、真实打款 provider、发票、真实生产账单抽样验收、AI 择校真实 provider、主题素材库/模板市场等仍是后续商用增强项。
|
- 数据看板、销售/代理转化看板、分佣结算、财务运营、勋章手动发放、签到/积分/反馈/积分活动/练习/单词/模考自动发放、积分任务/兑换第一阶段、积分风控只读报表、用户站内通知第一版和主题模板发布基础 API 已可联调;Taro 学生个人中心已接积分任务/兑换/积分明细第一版,租户营销中心已接积分任务/兑换配置、记录查看和风控摘要第一版;外部微信订阅消息/短信、真实打款 provider、发票、真实生产账单抽样验收、AI 择校真实 provider、主题素材库/模板市场等仍是后续商用增强项。
|
||||||
|
|
||||||
## 前后端协作建议
|
## 前后端协作建议
|
||||||
|
|
||||||
@@ -99,11 +99,11 @@
|
|||||||
| 数据看板 | `apps/taro/src/pages/tenant-admin/dashboard/index.tsx` | `tenant-admin/dashboard` |
|
| 数据看板 | `apps/taro/src/pages/tenant-admin/dashboard/index.tsx` | `tenant-admin/dashboard` |
|
||||||
| 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes`、`tenant-admin/teachers`、`tenant-admin/students`、`students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`、`students/followups/report`、`students/supervision/preview`、`students/supervision/generate`、`students/supervision/rules`、`students/crm-push` |
|
| 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes`、`tenant-admin/teachers`、`tenant-admin/students`、`students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`、`students/followups/report`、`students/supervision/preview`、`students/supervision/generate`、`students/supervision/rules`、`students/crm-push` |
|
||||||
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/detail`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/exports/questions`、`tenant-content/exports/jobs`、`tenant-content/assets/sign-download`、`tenant-content/assets/sign-preview`、`tenant-content/assets/security-scan-events`、`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`、`tenant-content/notifications/status` |
|
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/detail`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/exports/questions`、`tenant-content/exports/jobs`、`tenant-content/assets/sign-download`、`tenant-content/assets/sign-preview`、`tenant-content/assets/security-scan-events`、`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`、`tenant-content/notifications/status` |
|
||||||
| 营销中心 | `apps/taro/src/pages/tenant-admin/marketing/index.tsx` | `tenant-admin/coupons`、`code-batches`、`activation-codes`、`crm/config`、`crm/queue`、`crm/dead-letters`、`crm/queue/logs`、`crm/queue/action`、`commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status`、`tenant-admin/feedbacks/report`、`tenant-admin/point-activity-tasks`、`tenant-admin/point-activity-claims`、`tenant-admin/point-exchange-items`、`tenant-admin/point-exchange-orders`、`tenant-admin/points-risk-report`、`tenant-admin/user-notifications`;已接 CRM 配置、队列、死信失败池、脱敏日志、重试/忽略、分佣、优惠券规则/核销报表、反馈运营摘要、积分任务/兑换配置与记录查看、积分风控摘要、用户通知查看第一版 |
|
| 营销中心 | `apps/taro/src/pages/tenant-admin/marketing/index.tsx` | `tenant-admin/coupons`、`code-batches`、`activation-codes`、`crm/config`、`crm/queue`、`crm/dead-letters`、`crm/queue/logs`、`crm/queue/action`、`referral/conversion-report`、`commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status`、`tenant-admin/feedbacks/report`、`tenant-admin/point-activity-tasks`、`tenant-admin/point-activity-claims`、`tenant-admin/point-exchange-items`、`tenant-admin/point-exchange-orders`、`tenant-admin/points-risk-report`、`tenant-admin/user-notifications`;已接 CRM 配置、队列、死信失败池、脱敏日志、重试/忽略、销售/代理转化、分佣、优惠券规则/核销报表、反馈运营摘要、积分任务/兑换配置与记录查看、积分风控摘要、用户通知查看第一版 |
|
||||||
| 财务运营 | `apps/taro/src/pages/tenant-admin/finance/index.tsx` | `commerce/refunds`、`commerce/refunds/status`、`commerce/operations/anomalies`、`commerce/reconciliation/batches`、`commerce/reconciliation/items`、`commerce/reconciliation/issues/create`、`commerce/reconciliation/issues`、`commerce/reconciliation/issues/status`、`commerce/reconciliation/provider-bills/request`、`commerce/reconciliation/provider-bills/jobs`、`commerce/adjustment-vouchers`、`commerce/adjustment-vouchers/status`、`commerce/adjustment-vouchers/report` |
|
| 财务运营 | `apps/taro/src/pages/tenant-admin/finance/index.tsx` | `commerce/refunds`、`commerce/refunds/status`、`commerce/operations/anomalies`、`commerce/reconciliation/batches`、`commerce/reconciliation/items`、`commerce/reconciliation/issues/create`、`commerce/reconciliation/issues`、`commerce/reconciliation/issues/status`、`commerce/reconciliation/provider-bills/request`、`commerce/reconciliation/provider-bills/jobs`、`commerce/adjustment-vouchers`、`commerce/adjustment-vouchers/status`、`commerce/adjustment-vouchers/report` |
|
||||||
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`theme-templates`、`theme`、`theme/preview`、`theme/publish`、`permissions`、`GET/PUT role-templates`、`POST role-templates/disable`、`GET/PUT members`、`POST members/disable` |
|
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`theme-templates`、`theme`、`theme/preview`、`theme/publish`、`permissions`、`GET/PUT role-templates`、`POST role-templates/disable`、`GET/PUT members`、`POST members/disable` |
|
||||||
|
|
||||||
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;学生运营页支持学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务、完成跟进、跟进看板、学习督导候选预览/一键生成跟进任务、保存每日督导规则和 CRM 入队推送;题库内容页支持公共题库采纳/同步、同步通知查看与已读/忽略、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情,也可接 JSON/试卷 payload 同步导出与 PDF/Word 异步导出 job 轮询,完成后用 `assetId` 走后台资源签名下载/预览;营销中心支持 CRM 配置保存、队列按状态查看、死信失败池、脱敏日志、手动重试/忽略、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则/核销报表、反馈运营摘要、积分任务/兑换配置、领取/兑换记录查看、积分风控只读摘要和用户通知查看第一版;财务运营页支持退款状态流、官方账单任务、对账异常、差错工单和调整凭证,且只通过后端命令层写审计与状态;租户设置页支持主题模板选择、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补更精细的学生导入模板体验、更细数据范围 UI、学习督导触达联动/效果归因、主题素材库、真实打款 provider、发票、真实生产账单抽样验收和小程序端兼容。
|
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;学生运营页支持学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务、完成跟进、跟进看板、学习督导候选预览/一键生成跟进任务、保存每日督导规则和 CRM 入队推送;题库内容页支持公共题库采纳/同步、同步通知查看与已读/忽略、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情,也可接 JSON/试卷 payload 同步导出与 PDF/Word 异步导出 job 轮询,完成后用 `assetId` 走后台资源签名下载/预览;营销中心支持 CRM 配置保存、队列按状态查看、死信失败池、脱敏日志、手动重试/忽略、销售/代理转化报表、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则/核销报表、反馈运营摘要、积分任务/兑换配置、领取/兑换记录查看、积分风控只读摘要和用户通知查看第一版;财务运营页支持退款状态流、官方账单任务、对账异常、差错工单和调整凭证,且只通过后端命令层写审计与状态;租户设置页支持主题模板选择、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补更精细的学生导入模板体验、更细数据范围 UI、学习督导触达联动/效果归因、主题素材库、真实打款 provider、发票、真实生产账单抽样验收和小程序端兼容。
|
||||||
|
|
||||||
## 已落地的 Taro 平台后台页面
|
## 已落地的 Taro 平台后台页面
|
||||||
|
|
||||||
|
|||||||
@@ -43,9 +43,9 @@
|
|||||||
| 旧功能/组件 | 新后端状态 | 待补齐 |
|
| 旧功能/组件 | 新后端状态 | 待补齐 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 用户管理 | 已覆盖 | 租户成员、学生列表、学生资料、批量学生 upsert、禁用/恢复、批量分班、学生备注、跟进任务、跟进效果统计、学习督导自动化预览/生成、督导规则模板、定时生成 worker 和批量 CRM 推送已实现;后续补更细导入模板体验、更细数据范围 UI、触达联动和效果归因 |
|
| 用户管理 | 已覆盖 | 租户成员、学生列表、学生资料、批量学生 upsert、禁用/恢复、批量分班、学生备注、跟进任务、跟进效果统计、学习督导自动化预览/生成、督导规则模板、定时生成 worker 和批量 CRM 推送已实现;后续补更细导入模板体验、更细数据范围 UI、触达联动和效果归因 |
|
||||||
| 销售/代理管理 | 部分覆盖 | referral/team/stats 已有;分佣设置、订单/激活码归因、结算单生成、审核、打款状态、CSV/JSON 导出、打款凭证登记/复核和本人/全局权限范围已覆盖;还缺真实打款 provider、发票、批量凭证上传和分佣看板 |
|
| 销售/代理管理 | 部分覆盖 | referral/team/stats 已有;分佣设置、订单/激活码归因、结算单生成、审核、打款状态、CSV/JSON 导出、打款凭证登记/复核、销售/代理转化报表和本人/全局权限范围已覆盖;还缺真实打款 provider、发票、批量凭证上传、团队预聚合和更细异常调整单 |
|
||||||
| 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 |
|
| 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 |
|
||||||
| 数据看板 | 部分覆盖 | 租户 dashboard 聚合 API 已覆盖收益、注册、答题、活跃、题型、科目、题量、套餐销量、运营动态、24h 活跃和激活码使用;后续补预聚合 worker、销售转化和分佣结算看板 |
|
| 数据看板 | 部分覆盖 | 租户 dashboard 聚合 API 已覆盖收益、注册、答题、活跃、题型、科目、题量、套餐销量、运营动态、24h 活跃和激活码使用;销售/代理转化看板第一版已覆盖漏斗、成交、佣金、CRM 失败和未成交客资;后续补预聚合 worker、缓存和慢 SQL 监控 |
|
||||||
| 地区管理 | 部分覆盖 | 地区和内容入口已有;平台公共题库已可按 SaaS 套餐/租户授权并由租户采纳;还缺更完整的全国/单地区套餐 UI 和版本同步策略 |
|
| 地区管理 | 部分覆盖 | 地区和内容入口已有;平台公共题库已可按 SaaS 套餐/租户授权并由租户采纳;还缺更完整的全国/单地区套餐 UI 和版本同步策略 |
|
||||||
| 品牌配置 | 已覆盖 | 品牌基础配置、主题模板、草稿预览、发布和公开解析已有;后续补素材库和更精细视觉编辑器 |
|
| 品牌配置 | 已覆盖 | 品牌基础配置、主题模板、草稿预览、发布和公开解析已有;后续补素材库和更精细视觉编辑器 |
|
||||||
| 自定义域名 | 已覆盖 | 生产需补 DNS 校验、证书状态、回源校验 |
|
| 自定义域名 | 已覆盖 | 生产需补 DNS 校验、证书状态、回源校验 |
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 |
|
| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 |
|
||||||
| 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 |
|
| 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 |
|
||||||
| 用户站内通知 | 部分覆盖 | 反馈处理、反馈奖励、勋章发放和积分兑换会写入 `user_notifications`,学生可查/标记状态,租户后台可按权限查看租户内通知;Taro 学生个人中心已接消息摘要,独立消息中心已接状态/类型筛选、批量已读、已读/归档/忽略和站内安全跳转第一版,租户营销中心已接用户通知查看第一版 | 外部微信订阅消息/短信、批量统计和运营效果看板待补 |
|
| 用户站内通知 | 部分覆盖 | 反馈处理、反馈奖励、勋章发放和积分兑换会写入 `user_notifications`,学生可查/标记状态,租户后台可按权限查看租户内通知;Taro 学生个人中心已接消息摘要,独立消息中心已接状态/类型筛选、批量已读、已读/归档/忽略和站内安全跳转第一版,租户营销中心已接用户通知查看第一版 | 外部微信订阅消息/短信、批量统计和运营效果看板待补 |
|
||||||
| CRM 配置和队列 | 部分覆盖 | 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、学生批量 CRM 推送、跟进效果统计、钉钉/飞书/企微 worker、签名、重试、死信失败池、脱敏日志、手动重试/忽略和审计已有;富卡片模板、外部失败告警升级和更细销售转化看板待补 |
|
| CRM 配置和队列 | 部分覆盖 | 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、学生批量 CRM 推送、跟进效果统计、销售/代理转化报表、钉钉/飞书/企微 worker、签名、重试、死信失败池、脱敏日志、手动重试/忽略和审计已有;富卡片模板、外部失败告警升级、转化预聚合和更细团队范围待补 |
|
||||||
| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计、水印 traceId、内置安全扫描和外部 HTTP scanner 接入层已有;租户级存储策略、CDN/转码级水印/真实 AV 服务联调待补 |
|
| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计、水印 traceId、内置安全扫描和外部 HTTP scanner 接入层已有;租户级存储策略、CDN/转码级水印/真实 AV 服务联调待补 |
|
||||||
|
|
||||||
## 平台 SaaS 后台功能
|
## 平台 SaaS 后台功能
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
4. 题库导出:服务端 JSON/试卷 payload、PDF/Word 二进制生成、水印、资料发布路径、权限审计、答案脱敏、每日一练基础导出和图片 ZIP 素材包已补;仍缺更精细试卷模板和后台导出操作台体验。
|
4. 题库导出:服务端 JSON/试卷 payload、PDF/Word 二进制生成、水印、资料发布路径、权限审计、答案脱敏、每日一练基础导出和图片 ZIP 素材包已补;仍缺更精细试卷模板和后台导出操作台体验。
|
||||||
5. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、导入任务详情、模板下载按钮、字段映射 API、Taro 字段别名编辑、异步轮询和 PocketBase JSON dry-run 报告已补,仍缺真实数据执行验收。
|
5. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、导入任务详情、模板下载按钮、字段映射 API、Taro 字段别名编辑、异步轮询和 PocketBase JSON dry-run 报告已补,仍缺真实数据执行验收。
|
||||||
6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、同步通知、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需生产定时调度、失败告警和更完整运营后台消息。
|
6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、同步通知、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需生产定时调度、失败告警和更完整运营后台消息。
|
||||||
7. CRM/销售结算:CRM worker、跟进分配策略、学生批量 CRM 推送、跟进效果统计、死信失败池、脱敏日志、手动重试/忽略、分佣规则、结算单、审核、打款状态、导出和凭证复核基础闭环已完成;仍缺富卡片模板、外部失败告警升级、真实打款 provider、发票和销售结算看板。
|
7. CRM/销售结算:CRM worker、跟进分配策略、学生批量 CRM 推送、跟进效果统计、销售/代理转化报表、死信失败池、脱敏日志、手动重试/忽略、分佣规则、结算单、审核、打款状态、导出和凭证复核基础闭环已完成;仍缺富卡片模板、外部失败告警升级、真实打款 provider、发票、转化预聚合和更细销售团队看板。
|
||||||
8. AI 择校推荐增强:后端 `local_rules` 地基、SVIP 门禁、报告列表/详情、本人报告 Markdown/HTML 导出和 Taro 基础页已完成;仍缺真实 AI provider、prompt 编排、PDF worker 报告和后台运营配置。
|
8. AI 择校推荐增强:后端 `local_rules` 地基、SVIP 门禁、报告列表/详情、本人报告 Markdown/HTML 导出和 Taro 基础页已完成;仍缺真实 AI provider、prompt 编排、PDF worker 报告和后台运营配置。
|
||||||
9. 题目反馈增强:站内通知后端第一版已完成;仍缺前端消息中心、外部订阅消息/短信提醒、问题聚合统计和内容修复闭环。
|
9. 题目反馈增强:站内通知后端第一版已完成;仍缺前端消息中心、外部订阅消息/短信提醒、问题聚合统计和内容修复闭环。
|
||||||
10. 积分活动增强:积分兑换、活动任务、优惠券兑换履约、后台配置和积分风控只读报表第一阶段已完成;仍缺活动效果看板和更细系统任务触发。
|
10. 积分活动增强:积分兑换、活动任务、优惠券兑换履约、后台配置和积分风控只读报表第一阶段已完成;仍缺活动效果看板和更细系统任务触发。
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
1. 微信/支付宝支付和 webhook 幂等。
|
1. 微信/支付宝支付和 webhook 幂等。
|
||||||
2. 对象存储真实 AV/内容安全服务联调、视频深度防盗链、转码级水印。
|
2. 对象存储真实 AV/内容安全服务联调、视频深度防盗链、转码级水印。
|
||||||
3. 真实数据 dry-run 执行验收、导入抽样校验和性能压测。
|
3. 真实数据 dry-run 执行验收、导入抽样校验和性能压测。
|
||||||
4. 数据看板预聚合 worker、销售/代理转化看板和分佣结算。
|
4. 数据看板预聚合 worker、销售/代理转化预聚合、真实打款 provider、发票和异常调整单。
|
||||||
5. 公共题库生产定时调度、失败告警、租户确认/跳过策略和更完整运营消息。
|
5. 公共题库生产定时调度、失败告警、租户确认/跳过策略和更完整运营消息。
|
||||||
|
|
||||||
### P2:增强体验
|
### P2:增强体验
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
- 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。
|
- 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。
|
||||||
- 平台后台 API/worker:租户、租户详情、账务资料维护、平台员工列表/创建/编辑/启停、平台审计日志查询/导出、平台审计告警规则/列表/确认/解决、审计告警外部通知渠道/事件、platform-audit-alerts worker、platform-audit-notifications worker、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件、platform-dunning-notifications worker、用量、套餐额度判定和用量超额账单。
|
- 平台后台 API/worker:租户、租户详情、账务资料维护、平台员工列表/创建/编辑/启停、平台审计日志查询/导出、平台审计告警规则/列表/确认/解决、审计告警外部通知渠道/事件、platform-audit-alerts worker、platform-audit-notifications worker、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件、platform-dunning-notifications worker、用量、套餐额度判定和用量超额账单。
|
||||||
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核;Taro 租户营销中心已接 CRM、分佣和优惠券规则/核销报表第一版操作台。
|
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核;Taro 租户营销中心已接 CRM、分佣和优惠券规则/核销报表第一版操作台。
|
||||||
|
- 销售/代理转化报表第一版已完成:`GET /api/referral/conversion-report` 由后端统一聚合触达/扫码、新增客资、已分配、有效成交、成交来源、成交金额、预计佣金、CRM 失败、跟进积压、成员排行、日趋势和近期未成交客资;租户管理员可看全局或指定成员,销售/代理只能看本人,Taro 租户营销中心已接第一版看板。
|
||||||
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
||||||
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
|
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
|
||||||
- 模考报告与学习统计:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询;`/api/learning/stats`、`trend`、`practice-sessions/history`、`wrong-questions/review-plan` 可支撑个人中心和学习报告基础页。
|
- 模考报告与学习统计:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询;`/api/learning/stats`、`trend`、`practice-sessions/history`、`wrong-questions/review-plan` 可支撑个人中心和学习报告基础页。
|
||||||
@@ -152,13 +153,14 @@
|
|||||||
10. 数据看板
|
10. 数据看板
|
||||||
- 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。
|
- 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。
|
||||||
- 继续补日/周/月预聚合 worker、缓存策略、慢 SQL 监控和大租户性能压测。
|
- 继续补日/周/月预聚合 worker、缓存策略、慢 SQL 监控和大租户性能压测。
|
||||||
- 分佣结算基础闭环已完成;继续补销售/代理转化、真实打款 provider、发票、异常调整单和客资跟进效果看板。
|
- 分佣结算和销售/代理转化报表第一版已完成;继续补转化预聚合、真实打款 provider、发票、异常调整单和更细客资跟进效果看板。
|
||||||
|
|
||||||
11. 销售/代理分佣
|
11. 销售/代理分佣
|
||||||
- 已完成租户默认分佣比例、成员分佣比例、激活码批次分佣比例。
|
- 已完成租户默认分佣比例、成员分佣比例、激活码批次分佣比例。
|
||||||
- 已完成订单和激活码两类来源的佣金归因,且只统计客资绑定后的成交。
|
- 已完成订单和激活码两类来源的佣金归因,且只统计客资绑定后的成交。
|
||||||
- 已完成结算单生成、重复结算拦截、审核、打款状态、已打款锁定、CSV/JSON 导出、打款凭证登记、凭证复核、租户隔离和销售/代理本人范围权限。
|
- 已完成结算单生成、重复结算拦截、审核、打款状态、已打款锁定、CSV/JSON 导出、打款凭证登记、凭证复核、租户隔离和销售/代理本人范围权限。
|
||||||
- 继续补真实打款 provider、发票管理、批量凭证上传、销售团队分佣看板和异常调整单。
|
- 已完成销售/代理转化看板第一版,覆盖成交漏斗、成员排行、CRM 失败、跟进积压和近期未成交客资。
|
||||||
|
- 继续补真实打款 provider、发票管理、批量凭证上传、销售团队分佣看板、转化预聚合和异常调整单。
|
||||||
|
|
||||||
12. 学生运营管理
|
12. 学生运营管理
|
||||||
- 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注、跟进任务、跟进效果统计和批量 CRM 推送;学生写入口拒绝头像 URL、第三方头像字段和 `primaryRole`,避免绕过预设头像与租户角色体系。
|
- 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注、跟进任务、跟进效果统计和批量 CRM 推送;学生写入口拒绝头像 URL、第三方头像字段和 `primaryRole`,避免绕过预设头像与租户角色体系。
|
||||||
@@ -190,7 +192,7 @@
|
|||||||
- 已完成 `apps/worker` CRM 队列消费、generic webhook、钉钉、飞书、企业微信机器人 adapter、签名、失败重试和日志。
|
- 已完成 `apps/worker` CRM 队列消费、generic webhook、钉钉、飞书、企业微信机器人 adapter、签名、失败重试和日志。
|
||||||
- 已完成 CRM 配置中的跟进分配策略:`none/direct/round_robin/referrer`,后端校验候选人必须是当前租户内 active 的销售/代理/运营/教师/管理员,首绑客资后写入 `assignedToUserId` 并把 assignee 放进 CRM 队列 payload。
|
- 已完成 CRM 配置中的跟进分配策略:`none/direct/round_robin/referrer`,后端校验候选人必须是当前租户内 active 的销售/代理/运营/教师/管理员,首绑客资后写入 `assignedToUserId` 并把 assignee 放进 CRM 队列 payload。
|
||||||
- 已完成死信失败池、脱敏日志查看、手动重试/忽略和审计闭环,Taro 租户营销中心已接第一版操作台。
|
- 已完成死信失败池、脱敏日志查看、手动重试/忽略和审计闭环,Taro 租户营销中心已接第一版操作台。
|
||||||
- 继续补富卡片模板、外部失败告警升级和更细销售转化看板。
|
- 销售转化看板第一版已完成;继续补富卡片模板、外部失败告警升级、转化预聚合和更细团队数据范围。
|
||||||
|
|
||||||
4. 运维
|
4. 运维
|
||||||
- 后台操作审计报表。
|
- 后台操作审计报表。
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
## 结果
|
## 结果
|
||||||
|
|
||||||
|
### 120 秒阶梯基线
|
||||||
|
|
||||||
| 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 |
|
| 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 |
|
||||||
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
| 30 | 120s | 10% | 112,896 | 0.00% | 934.74 req/s | 64.26 ms | 81.14 ms |
|
| 30 | 120s | 10% | 112,896 | 0.00% | 934.74 req/s | 64.26 ms | 81.14 ms |
|
||||||
@@ -22,10 +24,20 @@
|
|||||||
| 100 | 120s | 8% | 84,608 | 0.00% | 699.27 req/s | 254.69 ms | 331.84 ms |
|
| 100 | 120s | 8% | 84,608 | 0.00% | 699.27 req/s | 254.69 ms | 331.84 ms |
|
||||||
| 150 | 120s | 6% | 82,693 | 0.00% | 682.40 req/s | 369.90 ms | 493.69 ms |
|
| 150 | 120s | 6% | 82,693 | 0.00% | 682.40 req/s | 369.90 ms | 493.69 ms |
|
||||||
|
|
||||||
|
### 60 秒复测快照
|
||||||
|
|
||||||
|
2026-06-30 晚重新 `supabase db reset`、导入同一批 PocketBase 真实 JSON 后复跑,`pb:import:validate` 为 0 failures/3 warnings,`pb:import:sample` 为 0 failures/6 warnings/1 skipped/39 passed。
|
||||||
|
|
||||||
|
| 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 |
|
||||||
|
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| 30 | 60s | 10% | 64,025 | 0.00% | 1052.52 req/s | 55.14 ms | 70.15 ms |
|
||||||
|
| 100 | 60s | 8% | 59,123 | 0.00% | 969.92 req/s | 179.70 ms | 235.19 ms |
|
||||||
|
| 150 | 60s | 6% | 53,167 | 0.00% | 870.06 req/s | 298.23 ms | 402.89 ms |
|
||||||
|
|
||||||
## 初步结论
|
## 初步结论
|
||||||
|
|
||||||
- 本地 Docker 环境下,100 个无停顿 worker 内 P95 仍低于 300ms,可以作为当前代码和索引状态的本地舒适区参考。
|
- 本地 Docker 环境下,100 个无停顿 worker 内 P95 仍低于 300ms,可以作为当前代码和索引状态的本地舒适区参考。
|
||||||
- 150 个无停顿 worker 仍然 0 错误,但 P95 上升到约 370ms,已经能看到压力拐点。
|
- 150 个无停顿 worker 仍然 0 错误,但 P95 在不同轮次中接近或超过 300ms,已经能看到本地压力拐点。
|
||||||
- 如果未来前端真实埋点显示每名在线学生平均 0.05 到 0.2 req/s,则 700 req/s 理论吞吐约对应 3,500 到 14,000 名活跃在线学生请求量;这只是吞吐换算,不是生产 SLA。
|
- 如果未来前端真实埋点显示每名在线学生平均 0.05 到 0.2 req/s,则 700 req/s 理论吞吐约对应 3,500 到 14,000 名活跃在线学生请求量;这只是吞吐换算,不是生产 SLA。
|
||||||
- 正式容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、对象存储/CDN、真实前端请求节奏和生产网络下复跑。
|
- 正式容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、对象存储/CDN、真实前端请求节奏和生产网络下复跑。
|
||||||
|
|
||||||
|
|||||||
@@ -859,6 +859,7 @@ GET /api/tenant-admin/dashboard?timeRange=30d®ionId=<可选地区ID>&limit=10
|
|||||||
| 设置销售/代理个人比例 | `PUT /api/commission/member-rate` | `commission:write` |
|
| 设置销售/代理个人比例 | `PUT /api/commission/member-rate` | `commission:write` |
|
||||||
| 分佣汇总 | `GET /api/commission/summary?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&referrerUserId=...` | `commission:read` 或 `commission:self` |
|
| 分佣汇总 | `GET /api/commission/summary?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&referrerUserId=...` | `commission:read` 或 `commission:self` |
|
||||||
| 分佣来源明细 | `GET /api/commission/orders?...` | `commission:read` 或 `commission:self` |
|
| 分佣来源明细 | `GET /api/commission/orders?...` | `commission:read` 或 `commission:self` |
|
||||||
|
| 销售/代理转化报表 | `GET /api/referral/conversion-report?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&referrerUserId=...` | `referral:read` 或 `referral:self` |
|
||||||
| 结算单列表 | `GET /api/commission/settlements?...` | `commission:read` 或 `commission:self` |
|
| 结算单列表 | `GET /api/commission/settlements?...` | `commission:read` 或 `commission:self` |
|
||||||
| 导出结算明细 | `GET /api/commission/settlements/export?settlementId=...&format=csv` | `commission:read` 或 `commission:self` |
|
| 导出结算明细 | `GET /api/commission/settlements/export?settlementId=...&format=csv` | `commission:read` 或 `commission:self` |
|
||||||
| 生成结算单 | `POST /api/commission/settlements/generate` | `commission:write` |
|
| 生成结算单 | `POST /api/commission/settlements/generate` | `commission:write` |
|
||||||
@@ -904,6 +905,35 @@ approved -> cancelled
|
|||||||
- `referrerUserId` 是受首绑保护的推广/分佣归属;CRM 的 `assignedToUserId` 只是跟进负责人,不能作为分佣结算依据。
|
- `referrerUserId` 是受首绑保护的推广/分佣归属;CRM 的 `assignedToUserId` 只是跟进负责人,不能作为分佣结算依据。
|
||||||
- 当前版本支持线下打款状态登记、结算导出、凭证登记和凭证复核;真实银行/微信/支付宝打款 provider、发票和批量凭证上传后续增强。
|
- 当前版本支持线下打款状态登记、结算导出、凭证登记和凭证复核;真实银行/微信/支付宝打款 provider、发票和批量凭证上传后续增强。
|
||||||
|
|
||||||
|
### 销售/代理转化报表
|
||||||
|
|
||||||
|
销售/代理转化报表由后端聚合客资、订单、激活码、CRM 队列和跟进任务,前端不要自行读取这些表后拼统计。接口:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/referral/conversion-report?startDate=2026-06-01&endDate=2026-06-30&referrerUserId=<可选>&limit=30
|
||||||
|
```
|
||||||
|
|
||||||
|
返回结构包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
item.range
|
||||||
|
item.summary
|
||||||
|
item.funnel
|
||||||
|
item.byReferrer
|
||||||
|
item.dailyTrend
|
||||||
|
item.recentUnconvertedLeads
|
||||||
|
```
|
||||||
|
|
||||||
|
前端展示建议:
|
||||||
|
|
||||||
|
- 租户管理员、运营等有 `referral:read` 的账号可看全局或指定成员;销售/代理通常只有 `referral:self`,后端会强制只返回本人数据。
|
||||||
|
- `summary.leadCount/convertedLeadCount/paidSourceCount/grossAmountCents/commissionAmountCents/crmFailedCount/openFollowupCount` 可作为顶部指标。
|
||||||
|
- `funnel` 直接展示触达、客资、已分配、有效成交、成交来源漏斗,不要在前端重新计算。
|
||||||
|
- `byReferrer` 展示成员排行,`conversionRate/paidLeadRate` 是 0 到 1 的比例。
|
||||||
|
- `recentUnconvertedLeads` 用于运营跟进,但只能展示后端返回字段;不要把它当成修改客资归属的入口。
|
||||||
|
- 区分 `referrerUserId` 和 `assignedToUserId`:前者是首绑保护下的推广/分佣归属,后者只是 CRM 跟进负责人。
|
||||||
|
- 大租户上线后会补日/小时预聚合;前端只消费该接口,不依赖当前 SQL 的中间口径。
|
||||||
|
|
||||||
### 背单词计划与复习上报
|
### 背单词计划与复习上报
|
||||||
|
|
||||||
背单词页面分三类数据:单元列表、每日计划、单词进度。前端不需要计算下次复习日期,只提交“认识/不认识”,由后端统一更新 `nextReviewDate`、连续正确、掌握状态和每日复习计划。
|
背单词页面分三类数据:单元列表、每日计划、单词进度。前端不需要计算下次复习日期,只提交“认识/不认识”,由后端统一更新 `nextReviewDate`、连续正确、掌握状态和每日复习计划。
|
||||||
@@ -3164,7 +3194,7 @@ GET /api/ai/school-recommendations/export?reportId=<reportId>&format=html
|
|||||||
- 勋章:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`
|
- 勋章:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`
|
||||||
- 考试日期:`GET/PUT /api/tenant-admin/exam-dates`
|
- 考试日期:`GET/PUT /api/tenant-admin/exam-dates`
|
||||||
- 题目反馈:`GET /api/tenant-admin/feedbacks`、`GET /api/tenant-admin/feedbacks/report`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events`
|
- 题目反馈:`GET /api/tenant-admin/feedbacks`、`GET /api/tenant-admin/feedbacks/report`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events`
|
||||||
- 销售/代理/CRM 队列、CRM 配置、CRM 跟进分配策略、CRM 死信失败池、脱敏日志、手动重试/忽略、分佣规则、成员分佣比例、分佣订单、结算单审核和线下打款登记;当前 Taro 租户营销中心已接 CRM 配置、队列、死信失败池、日志、重试/忽略、分佣、优惠券规则和核销报表第一版,财务运营页已接退款、官方账单、对账异常、差错工单和调整凭证第一版。真实打款 provider、发票、生产账单抽样验收和更完整财务复核体验后续增强。
|
- 销售/代理/CRM 队列、CRM 配置、CRM 跟进分配策略、CRM 死信失败池、脱敏日志、手动重试/忽略、销售/代理转化报表、分佣规则、成员分佣比例、分佣订单、结算单审核和线下打款登记;当前 Taro 租户营销中心已接 CRM 配置、队列、死信失败池、日志、重试/忽略、销售/代理转化、分佣、优惠券规则和核销报表第一版,财务运营页已接退款、官方账单、对账异常、差错工单和调整凭证第一版。真实打款 provider、发票、生产账单抽样验收和更完整财务复核体验后续增强。
|
||||||
|
|
||||||
CRM 分配策略由后端执行,前端只提交配置:
|
CRM 分配策略由后端执行,前端只提交配置:
|
||||||
|
|
||||||
|
|||||||
@@ -10204,6 +10204,49 @@ async function testReferralAndCrmGrowth() {
|
|||||||
assert.equal(agentCommissionSummary.item?.grossAmountCents >= 2990, true, 'agent commission summary should include gross amount');
|
assert.equal(agentCommissionSummary.item?.grossAmountCents >= 2990, true, 'agent commission summary should include gross amount');
|
||||||
assert.equal(agentCommissionSummary.item?.commissionAmountCents >= 1217, true, 'agent commission should apply member and batch rates');
|
assert.equal(agentCommissionSummary.item?.commissionAmountCents >= 1217, true, 'agent commission should apply member and batch rates');
|
||||||
|
|
||||||
|
const conversionReport = await request('/api/referral/conversion-report', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
query: { startDate: today, endDate: today, referrerUserId: TENANT_AGENT_USER_ID, limit: 10 },
|
||||||
|
});
|
||||||
|
assert.equal(conversionReport.item?.range?.referrerUserId, TENANT_AGENT_USER_ID, 'conversion report should support referrer filter');
|
||||||
|
assert.equal(conversionReport.item?.summary?.leadCount >= 1, true, 'conversion report should include protected leads');
|
||||||
|
assert.equal(conversionReport.item?.summary?.paidSourceCount >= 2, true, 'conversion report should include paid order and activation code');
|
||||||
|
assert.equal(conversionReport.item?.summary?.grossAmountCents >= 2990, true, 'conversion report should include gross amount');
|
||||||
|
assert.equal(conversionReport.item?.summary?.commissionAmountCents >= 1217, true, 'conversion report should reuse commission rate precedence');
|
||||||
|
assert.equal(conversionReport.item?.summary?.conversionRate > 0, true, 'conversion report should calculate conversion rate');
|
||||||
|
assert.ok(conversionReport.item?.byReferrer?.some(item => item.referrerUserId === TENANT_AGENT_USER_ID), 'conversion report should group by referrer');
|
||||||
|
assert.ok(conversionReport.item?.dailyTrend?.some(item => item.date === today && Number(item.paidSourceCount || 0) >= 2), 'conversion report should include daily paid sources');
|
||||||
|
assert.ok(conversionReport.item?.funnel?.some(item => item.key === 'converted'), 'conversion report should include funnel summary');
|
||||||
|
assert.ok(!JSON.stringify(conversionReport).includes('crm-secret-smoke'), 'conversion report must not leak CRM secret');
|
||||||
|
|
||||||
|
const agentConversionReport = await request('/api/referral/conversion-report', {
|
||||||
|
userId: TENANT_AGENT_USER_ID,
|
||||||
|
query: { startDate: today, endDate: today },
|
||||||
|
});
|
||||||
|
assert.equal(agentConversionReport.item?.range?.referrerUserId, TENANT_AGENT_USER_ID, 'agent conversion report should be scoped to self');
|
||||||
|
assert.ok(agentConversionReport.item?.byReferrer?.every(item => item.referrerUserId === TENANT_AGENT_USER_ID), 'agent conversion report should not expose other referrers');
|
||||||
|
|
||||||
|
const agentOtherConversionDenied = await request('/api/referral/conversion-report', {
|
||||||
|
userId: TENANT_AGENT_USER_ID,
|
||||||
|
query: { startDate: today, endDate: today, referrerUserId: TENANT_SALES_USER_ID },
|
||||||
|
expectStatus: 403,
|
||||||
|
});
|
||||||
|
assert.equal(agentOtherConversionDenied.code, 'REFERRAL_SCOPE_REQUIRED', 'agent should not read another referrer conversion report');
|
||||||
|
|
||||||
|
const studentConversionDenied = await request('/api/referral/conversion-report', {
|
||||||
|
query: { startDate: today, endDate: today },
|
||||||
|
expectStatus: 403,
|
||||||
|
});
|
||||||
|
assert.equal(studentConversionDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access conversion report');
|
||||||
|
|
||||||
|
const partnerConversionDenied = await request('/api/referral/conversion-report', {
|
||||||
|
tenantId: PARTNER_TENANT_ID,
|
||||||
|
userId: TENANT_AGENT_USER_ID,
|
||||||
|
query: { startDate: today, endDate: today },
|
||||||
|
expectStatus: 403,
|
||||||
|
});
|
||||||
|
assert.equal(partnerConversionDenied.code, 'TENANT_ADMIN_REQUIRED', 'conversion report APIs must be tenant isolated');
|
||||||
|
|
||||||
const salesOtherCommissionDenied = await request('/api/commission/summary', {
|
const salesOtherCommissionDenied = await request('/api/commission/summary', {
|
||||||
userId: TENANT_AGENT_USER_ID,
|
userId: TENANT_AGENT_USER_ID,
|
||||||
query: { startDate: today, endDate: today, referrerUserId: TENANT_SALES_USER_ID },
|
query: { startDate: today, endDate: today, referrerUserId: TENANT_SALES_USER_ID },
|
||||||
|
|||||||
Reference in New Issue
Block a user