2 Commits

Author SHA1 Message Date
R524809 eddd7d75e2 docs: 3.5.0设计文档 2026-10-16 16:00:59 +08:00
R524809 08d9a2792a feat: 3.5版本规划 2026-09-24 17:14:38 +08:00
23 changed files with 791 additions and 46 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ function toHomeDisplayItem(ws) {
ageBand: ageBand(ws.ageMin || 0, ws.ageMax || 0),
difficulty: DIFFICULTY_LABELS[ws.difficulty] || '基础',
icon: '',
img: ws.previewImg || '',
previewImg: ws.previewImg || '',
badge: ws.isNew ? 'NEW' : ws.isHot ? 'HOT' : '',
path: ws.path || '',
available: true,
+13
View File
@@ -36,13 +36,26 @@ exports.main = async (event) => {
async function handleAdd(userId, event) {
const { worksheetId } = event;
const exportId = String(event.exportId || '').trim();
if (!worksheetId) return { code: -1, message: 'worksheetId 不能为空' };
const collection = db.collection('download_logs');
// 同一次成功导出重试时只能计一次,避免网络重试抬高下载量。
if (exportId) {
const { data: existing } = await collection
.where({ userId, exportId })
.limit(1)
.get();
if (existing.length > 0) {
return { code: 0, data: { ...existing[0], duplicate: true } };
}
}
const record = {
userId,
worksheetId,
...(exportId && { exportId }),
createdAt: db.serverDate(),
};
const { _id } = await collection.add({ data: record });
+2
View File
@@ -1,5 +1,7 @@
# Doodle / 涂鸦丫 - PC Web 端技术预案
> **2026-10-12 决策更新**:本文保留为历史预案,DAU 启动门槛和浏览器承载完整生成器不作为当前决策。现有后端已决定迁移到自有服务器,见 [迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md);Web 范围和私有服务端生成见 [v3.5 策略](./v3.5/Web与服务端生成策略.md)。
> 状态:**远期规划**(当前聚焦小程序,暂不启动 Web 开发)
> 最后更新:2026-03-24
> 配套文档:[技术架构设计文档](./技术架构设计文档.md) | [产品设计文档](./产品设计文档.md)
+33
View File
@@ -0,0 +1,33 @@
# Doodle Mini v3.5 规划索引
> 状态:产品规划待评审;云服务端迁移方向已确认,待实施
>
> 更新日期:2026-10-12
v3.5 的目标不是继续堆叠题型,而是把「生成一张练习纸」升级为家长可持续使用的家庭打印学习服务,并为未来 Web 内容平台建立服务端生成能力。
## 本阶段的四项决策
1. **小程序仍是当前主产品。** 优先改善家长首次找到内容、完成打印、再次回来使用的体验。
2. **创意涂色纸是下一项内容型 MVP。** 它覆盖低龄儿童、视觉传播力强,也最适合作为服务端生成的首个试点。
3. **现在开始建设 Web 底座,不启动完整 Web 平台。** 新增高价值题型优先接入服务端生成;既有小程序题型不做一次性迁移。
4. **现有业务后端迁移到自有云服务器。** 采用统一 API、MySQL 和七牛文件存储;CloudBase 只在切换期承担旧版兼容。业务后端迁移与存量 Canvas 服务端重写分别实施。
## 文档
| 文档 | 用途 |
| --- | --- |
| [产品路线图](./产品路线图.md) | 用户问题、功能优先级、阶段目标与验收指标 |
| [待办与里程碑](./待办与里程碑.md) | 可执行的产品、设计、研发待办与依赖关系 |
| [Web 与服务端生成策略](./Web与服务端生成策略.md) | Web 试点范围、服务端渲染边界及技术演进决策 |
| [云服务端迁移方案(doodle-store)](../../../doodle-store/docs/云服务端迁移方案.md) | 在新项目统一维护:现有功能/API 映射、数据与文件迁移、实施待办、切换回滚、验收及 AI 辅助工时预算 |
| [埋点事件字典](./埋点事件字典.md) | 现有事件和成功保存口径;后端迁移继续沿用 |
## 不在 v3.5 范围内
- 完整的 Kiddoleap 风格 Web 资源平台、全量 SEO 内容站。
- 在业务后端迁移中同时重写全部既有 Canvas 题型。
- 社区晒图、公开儿童信息、复杂学习报告。
- 会员支付、PDF 多页合并等商业化闭环;仅预留数据与服务端能力。
这些项目需要在 v3.5 的内容需求、留存和服务端生成成本被验证后再启动。
+153
View File
@@ -0,0 +1,153 @@
# Doodle Mini v3.5 Web 与服务端生成策略
> 状态:迁移到自有云服务器的方向已确认;Web 与绘图试点待实施
>
> 更新日期:2026-10-12
> 现有业务后端的迁移范围、切换顺序和验收以[云服务端迁移方案(doodle-store)](../../../doodle-store/docs/云服务端迁移方案.md)为准。本文说明迁移后 Web 与服务端生成的演进,不再以继续保留 CloudBase 业务后端为目标。
## 1. 决策结论
### 1.1 Web 的定位
Web 是长期的搜索获客、内容沉淀、电脑打印和教师/机构扩展渠道;小程序仍是当前的微信内打开、分享、每日推荐和家庭留存主产品。
v3.5 将现有业务后端迁移作为独立实施任务,小程序体验和小规模 Web/服务端生成按里程碑推进,不预设固定投入比例。
不等待小程序“达到很大用户量”才准备 Web,因为搜索内容、服务端渲染和内容生产机制都需要时间积累;但也不在内容库、内容生产和核心留存未验证时建设完整 Web 平台。
### 1.2 生成位置
未来 Web 的高价值题型采用**服务端生成与渲染优先**。浏览器负责选择参数、展示预览、发起下载和账户交互;不得承载完整题目生成规则、排版参数与私有素材。
小程序既有题型暂可保留客户端 Canvas 生成,以避免高风险的一次性重写。创意涂色纸及后续主题包、批量 PDF 等新能力必须按服务端模式实现。
## 2. 为什么不把核心生成放在 Web 客户端
Web 客户端代码、网络请求和静态资源可被用户查看、保存和分析。即使经过压缩或混淆,也只能提高复制成本,无法提供真正的保护。
服务端模式保护的是:
- 题目随机规则、难度控制、答案逻辑。
- 模板坐标、排版参数和批量内容生产能力。
- 原始 SVG/高清线稿、字体、商用素材以及素材关联关系。
- 未发布题型、主题包和会员规则。
服务端无法阻止他人观察最终 PNG/PDF 并模仿视觉效果;产品壁垒仍来自稳定的内容供给、素材版权、生成质量、运营数据和用户体验。
## 3. 目标架构
```text
小程序 / Web
└─ 仅提交受控参数:worksheetId、年龄、难度、主题、孩子姓名
↓
统一业务 API
↓
Render API(鉴权、配额、限流、参数校验)
↓
私有题型配置 + 私有素材 + 生成器 + 渲染器
↓
生成 PNG / PDF,写入对象存储
↓
返回短期签名预览/下载 URL 或异步任务状态
```
示例请求:
```json
{
"worksheetId": "coloring-animal-01",
"variant": "easy",
"childProfileId": "child_123",
"output": "png"
}
```
服务端根据 `worksheetId` 查询内部配置,生成随机种子和题目内容;客户端不能传素材 URL、任意渲染器名称、自由模板参数或数据库字段。
## 4. 数据与接口边界
### 4.1 平台无关的 worksheet 模型
当前内容数据中存在小程序专用 `path`,例如 `/mathPages/mathDraw/mathDraw?id=number-find`。v3.5 起应建立平台无关的核心字段:
```ts
type Worksheet = {
id: string;
category: string;
title: string;
ageMin: number;
ageMax: number;
difficulty: 1 | 2 | 3 | 4;
tags: string[];
rendererKey: string; // 仅服务端可解释
generatorKey: string; // 仅服务端可解释
assetRefs: string[]; // 私有资源引用
status: 'draft' | 'active' | 'hidden';
};
```
小程序路由与 Web URL 是展示层映射,不能作为内容本体。旧字段可在迁移期保留为 `miniProgramPath`,但新服务端生成不依赖它。
### 4.2 业务服务接口
页面不得再散落调用 `wx.cloud.callFunction`。逐步收口为业务接口:
```ts
worksheetService.list(filters)
worksheetService.getDetail(id)
renderService.createJob(request)
renderService.getJob(jobId)
favoriteService.toggle(worksheetId)
historyService.list()
```
新版小程序与后续 Web 统一使用 HTTP 业务 API;旧版云函数只在过渡期转发同一业务实现。上层页面不感知数据库与文件存储实现。
## 5. 后端演进路径
### 阶段 A:现有业务后端迁移
- 按专项方案迁移现有云函数、数据库、文件和定时任务到自有腾讯云服务器 + MySQL + 七牛。
- 新版小程序经业务服务层调用统一 HTTP API;短期兼容桥照顾旧正式版本,只有一个业务写入源。
- 先保持现有生成与保存行为,再验收数据关联、文件、计数和切换;不同时重写所有绘图代码。
### 阶段 B:服务端生成样板
- 在同一后端增加受控 Render API,以创意涂色或一个简单现有题型验证生成、字体、延迟和文件交付。
- 原始素材和模板采用私有存储;最终文件通过受控地址交付,不依赖隐藏对象路径保护素材。
- 根据实际任务量增加缓存/异步处理,不预先搭建独立队列平台。
### 阶段 C:Web 小规模试点与扩张
- Web 复用统一 API 和内容源,内容页面使用适当的预生成或服务端渲染,生成规则留在服务器。
- 只建设下节规定的 MVP;旧题型按价值与实际适配成本逐步接入,批量 PDF/会员另行评估。
- 私有生成进程可按资源需求拆分,但不另建用户、收藏、内容数据库或第二套业务规则。
## 6. 安全、成本与体验基线
| 维度 | v3.5 最低要求 |
| --- | --- |
| 素材保护 | 原始素材不公开;仅输出最终位图/PDF;下载链接短时有效 |
| 接口控制 | 登录/游客策略、参数白名单、按用户/IP 限流、每日生成配额 |
| 缓存 | 以题型、变体、随机种子、个性化字段计算缓存键;个性化内容谨慎缓存 |
| 任务模式 | 单张同步生成;多页学习包/PDF 使用异步任务与状态查询 |
| 可观测性 | 记录生成耗时、失败原因、缓存命中、文件大小、单位成本 |
| 体验目标 | 单张预览 P95 小于 3 秒;异步任务需有明确进度和失败重试提示 |
## 7. Web MVP 范围与升级门槛
Web MVP 仅包含:首页、10–20 个高意图主题/详情页、2–3 类生成器、预览与打印/PDF 下载。首选试点为创意涂色纸。
满足以下条件后,才扩张为完整 Kiddoleap 风格的资源平台:
- 可稳定按周上新内容,并有明确的素材/审核/发布流程。
- Web 已获得自然搜索访问、有效下载与回访,不只是小程序导流。
- 生成服务的时延、成功率和成本达到产品目标。
- 主题包或批量导出表现出明确的付费/订阅意愿。
## 8. 与既有文档的关系
本策略更新了既有架构文档中“前端为主、核心生成保持客户端执行”的长期方向:该原则仍适用于存量小程序题型的成本与离线体验,但**不适用于未来 Web 的高价值题型**。
在 v3.5 评审通过后,应同步更新《技术架构设计文档》《PC-Web端技术预案》《小程序云开发方案和数据库设计》中的阶段说明,避免文档之间出现相反结论。
+115
View File
@@ -0,0 +1,115 @@
# Doodle Mini v3.5 产品路线图
> 状态:待评审
>
> 更新日期:2026-09-24
## 1. 产品判断
涂鸦丫的核心用户不是“想要一道题”的孩子,而是希望快速安排孩子在家学习、又不愿每天筛选和购买练习册的家长。
当前产品已经具备动态生成 A4 练习纸、下载、收藏、分龄和题型分类能力。下一阶段应解决三件事:
1. **找得到:** 家长能按年龄、能力和主题快速找到需要的内容。
2. **用得起来:** 一次打开就能决定今天或本周打印什么,而不是在题型列表中犹豫。
3. **愿意回来:** 纸上完成的练习能形成轻量打卡、下次推荐和主题包复用。
因此,v3.5 的产品定位为:
> 为 3–8 岁家庭提供“今天就能打印、每周不必费心安排”的早教练习纸服务。
## 2. 用户问题与机会
| 用户问题 | 当前表现 | v3.5 机会 |
| --- | --- | --- |
| 不知道该给孩子练什么 | 有许多题型,但缺少任务化推荐 | 今日推荐、每周学习包、按年龄目标推荐 |
| 找一张特定练习纸费时 | 搜索逻辑存在,但界面未开放;分类偏题型 | 搜索、年龄/能力/主题筛选、主题集合页 |
| 低龄儿童可用内容与视觉吸引力不足 | 数学和书写题较多,3–5 岁的自由活动较少 | 创意涂色纸、颜色指令涂色、按数字涂色 |
| 打印后没有后续关系 | 仅有下载日志和收藏 | 完成打卡、连续练习、下次推荐 |
| 家长希望资料“属于孩子” | 练习纸为通用内容 | 孩子档案、姓名/日期/今日目标页眉 |
## 3. 功能优先级
### P0:先让核心体验完整
| 功能 | 用户价值 | v3.5 最小范围 | 成功指标 |
| --- | --- | --- | --- |
| 现有链路修复 | 防止用户打开错题、首页内容缺图 | 修复“识别形状”入口、时钟调试死链、首页推荐封面字段 | 关键入口 100% 可到达正确题型 |
| 搜索与筛选 | 快速定位练习纸 | 分类页恢复关键词搜索;增加年龄、能力/分类、主题标签 | 搜索后进入题型率;搜索零结果率 |
| 今日推荐 | 降低“今天练什么”的决策成本 | 每日 3 张:主练习、趣味活动、书写/语言;允许换一张 | 首页推荐点击率、首次导出率 |
| 创意涂色纸 MVP | 覆盖低龄、强化视觉吸引和传播 | 24–30 张原创/授权线稿;动物、食物水果、交通、恐龙、节日五主题 | 涂色纸导出率、收藏率、分享率 |
### P1:把单次下载变成每周复用
| 功能 | 用户价值 | v3.5/后续范围 |
| --- | --- | --- |
| 每周学习包 | 一键得到 5–7 天的打印安排 | 选择年龄、目标、每日时长;每天 2–3 张;可替换单张与收藏整包 |
| 孩子档案与个性化页眉 | 资料更有归属感、便于多孩家庭 | 孩子昵称、年龄、学习目标;导出时写入姓名、日期和练习主题 |
| 轻量完成打卡 | 建立线下练习后的回访理由 | 家长点击“已完成”;显示本周进度、连续天数和奖励贴纸 |
| 主题学习包 | 扩大内容复用与节日传播 | 如“恐龙周”“交通工具周”“入园准备包”“春节 7 日包” |
### P2:在数据验证后再做
| 功能 | 触发条件 | 说明 |
| --- | --- | --- |
| 家长难度反馈 | 推荐使用稳定后 | 每张练习只反馈“太简单 / 正好 / 有点难”,用于调整推荐 |
| 深度自定义 | 高意图用户占比明确 | 数字范围、题量、汉字词库、颜色主题等参数开放 |
| 教师/机构模式 | 有明确的机构试用用户 | 班级名单、姓名批量写入、随机变体、答案页 |
| 会员与 PDF 学习包 | 批量打印需求、支付意愿得到验证 | 多页 PDF、无广告、主题包、更多导出额度 |
## 4. 创意涂色纸 MVP
### 4.1 为什么优先做
- 面向 3–5 岁孩子,弥补“不会算、不愿写”的低龄使用场景。
- 首页预览和分享效果强,适合作为拉新入口。
- 与“打印后离开屏幕”的产品定位天然一致。
- 可作为 Web 服务端生成与私有素材管理的第一个试点。
### 4.2 第一版内容结构
| 类型 | 第一版内容 | 适龄 |
| --- | --- | --- |
| 自由创意涂色 | 动物、食物水果、交通、恐龙、节日五主题 | 3–6 岁 |
| 颜色指令涂色 | “把苹果涂红、鱼涂蓝”等识色/听指令任务 | 3–5 岁 |
| 按数字涂色 | 1–5 对应颜色,完成后呈现图案 | 4–6 岁 |
首发应准备 24–30 张高质量线稿,每主题 4–6 张。每张分为低龄版(轮廓大、元素少、留白多)与进阶版(背景、颜色任务或规律任务)。
### 4.3 非功能约束
- 仅使用原创、已购买商用授权,或经人工审核且可商用的素材;不得使用知名卡通 IP。
- 原始线稿、SVG 源文件和排版配置不得暴露给 Web 客户端。
- 预览可使用带水印低清图;下载生成清晰 A4 PNG/PDF。
- 每张内容具备主题、年龄、难度、关键词、版权来源和素材版本元数据。
## 5. 关键用户路径
```text
选择孩子/年龄
→ 首页看到“今天练什么”或主题包
→ 预览、替换或加入本周包
→ 生成并保存练习纸
→ 打印、纸上完成
→ 家长勾选已完成
→ 下次按年龄、目标与完成情况推荐
```
## 6. 验收指标
v3.5 不以“新增多少题型”验收,而以以下漏斗衡量:
| 指标 | 定义 | 初始目标 |
| --- | --- | --- |
| 首次导出转化率 | 新用户首次会话中成功保存练习纸的比例 | 持续提升;先建立基线 |
| 首次决策时长 | 打开首页至第一次点击“生成/导出”的时间 | 中位数低于 60 秒 |
| 7 日复用率 | 首次生成后 7 日内再次生成或下载的用户比例 | 持续提升;先建立基线 |
| 涂色内容表现 | 涂色纸的预览→导出、收藏、分享转化 | 高于当前题型中位数 |
| 学习包完成率 | 创建包后至少导出其中 3 张的比例 | 用于判断学习包是否成立 |
## 7. 明确暂不做
- 为了“看起来丰富”而新增大量同质数学题。
- 依赖儿童上传照片、公开晒图的社区功能。
- 没有验证批量使用需求前的多页 PDF 付费功能。
- 没有内容生产能力支撑前的完整 Web 分类站。
+25
View File
@@ -0,0 +1,25 @@
# Doodle Mini 埋点事件字典
> 更新日期:2026-09-24。事件名和字段名须先在微信小程序后台注册,再随版本发布。
## 通用字段
所有事件自动携带 `count`、`date_time`、`uuid`、`page_name`。内容事件使用稳定的 `worksheet_id`,而非可变的展示标题;可选字段包括 `worksheet_title`、`mode`、`source`、`category_id`、`export_id`、`duration_ms`、`fail_reason`。
| 事件 | 含义 | 触发时机 |
| --- | --- | --- |
| `page_view` | 页面浏览 | 首页、分类页首次进入 |
| `worksheet_click` | 点击某张练习纸 | 首页或分类列表点击有效内容 |
| `preview_view` | 打开或切换练习纸预览 | 练习纸页面初始化或切换题型 |
| `generate_start` | 开始生成 | 初始随机生成或点击“换一张” |
| `generate_success` / `generate_fail` | 生成结果 | Canvas 绘制结束;包含耗时和失败原因 |
| `export_click` | 点击保存 | 调用相册保存前 |
| `export_success` / `export_fail` | 导出结果 | 仅以相册保存 API 的结果为准 |
| `favorite_add` / `favorite_remove` | 收藏变化 | 云端收藏操作成功后 |
| `share_click` | 发起分享 | 调起小程序分享时 |
## 导出判定
`export_click` 不能作为下载量或转化。只有 `wx.saveImageToPhotosAlbum` 返回成功时才触发 `export_success`,并在同一时机递增本地下载配额、写入下载历史和更新内容下载量。
每次导出会生成 `export_id`。云端以 `userId + exportId` 去重,因此同一次保存的网络重试不会重复写下载历史或重复增加 `worksheets.downloads`。
+137
View File
@@ -0,0 +1,137 @@
# Doodle Mini v3.5 待办与里程碑
> 状态:待排期
>
> 更新日期:2026-10-12
## 0. 排期原则
按依赖关系推进:**先修复与可发现性 → 再验证涂色内容 → 再形成学习计划 → 同时建设小规模 Web/服务端样板。**
以下周期为相对迭代,不绑定具体人天;每个里程碑完成后应先检查数据,再决定是否扩大范围。
现有业务后端已决定迁移到自有云服务器。该专项按下列 S0–S5 执行,业务后端迁移与存量 Canvas 重写分别排期。详细清单、工时假设及切换步骤见[云服务端迁移方案(doodle-store)](../../../doodle-store/docs/云服务端迁移方案.md)。
## 服务端迁移专项(S0–S5)
- [ ] S0:盘点实际线上集合、云文件、服务器、域名和权限,保留原始备份及功能样本。
- [ ] S1:完成统一 API/数据库骨架、微信登录、题型读取、收藏/下载幂等事务。
- [ ] S2:补齐现有管理接口、首页配置/推荐、七牛文件上传与定时任务。
- [ ] S3:完成小程序 HTTP 接入、试迁审计、旧版兼容桥、恢复演练和体验版真机验收。
- [ ] S4:冻结旧写入,最终导入后切换唯一写入源,再发布新版并核对线上行为。
- [ ] S5:旧版及旧文件依赖达到退场条件后,归档并完全退出 CloudBase。
服务端生成样板在 S3 验收基础上单独实施;完整 Web 与新产品功能不计入这次现有功能迁移的完成标准。
## M0:体验与数据基线(第 1 个迭代)
### 产品与研发
- [x] 兼容 `shape-recognition` 的旧通用页路径,并统一本地/旧入口至独立“识别形状”页面,避免回退到错误题型。
- [x] 修复未上线调试页中时钟连线的过期路由。
- [x] 统一首页推荐数据字段,确保自动刷新结果使用 `previewImg`。
- [x] 为首页、分类、预览、生成、导出、收藏、分享补齐统一埋点口径。
- [x] 明确“成功导出”的唯一判定:实际保存成功后才记录下载、计数和转化。
### 产品验收
- [ ] 核对所有分类入口、首页推荐、分龄推荐和收藏历史中的跳转路径。
- [ ] 建立当前题型的预览→生成→保存漏斗基线。
- [ ] 建立按年龄段、分类和题型的下载/收藏基线。
### 完成标准
- 所有正式入口可打开预期题型;无静默回退到其他题型。
- 首页定时/手动推荐均能展示预览图。
- 可以从统计中区分“点击生成”和“实际成功保存”。
## M1:发现与选择(第 2 个迭代)
### 搜索与筛选
- [ ] 恢复分类页关键词搜索 UI,复用已有标题/副标题过滤能力。
- [ ] 增加年龄筛选:3–4、4–5、5–6、6–7、7–8 岁。
- [ ] 增加主题/标签筛选,优先支持动物、食物水果、交通、恐龙、节日等。
- [ ] 设计无结果状态和“推荐相近内容”兜底。
- [ ] 首页搜索入口只在具备完整结果页后开放,避免假搜索框。
### 今日推荐
- [ ] 定义推荐位:主练习、趣味练习、语言/书写练习各一张。
- [ ] 为推荐增加替换动作和推荐原因,如“适合 4–5 岁”“本周热门”。
- [ ] 明确手工运营、定时热度榜与分龄规则的优先级。
### 完成标准
- 家长能从首页或分类页在一分钟内筛出一个适龄题型。
- 推荐位可跳转、可更换、可追踪点击与导出。
## M2:创意涂色纸 MVP(第 3–4 个迭代)
### 内容与设计
- [ ] 制定线稿素材规范:A4 留白、安全线宽、低龄/进阶分级、预览图规格。
- [ ] 建立素材授权登记表:来源、授权类型、作者、可用范围、版本、审核人。
- [ ] 完成五主题 24–30 张首发线稿及预览图。
- [ ] 设计涂色纸题型卡、主题入口、预览页和“换一张”交互。
### 研发
- [ ] 增加 `coloring`/`craft` 业务分类及 worksheet 元数据。
- [ ] 建立涂色纸服务端生成样板,详见《Web 与服务端生成策略》。
- [ ] 支持低龄自由涂色与一类结构化玩法(颜色指令或按数字涂色)。
- [ ] 实现预览图、收藏、导出日志、下载统计和首页推荐接入。
- [ ] 对生成接口进行鉴权、限流、参数白名单和短期下载地址控制。
### 完成标准
- 首发素材均可追溯商用权利。
- 用户可完成“主题 → 预览 → 生成 → 保存”的闭环。
- 原始素材与生成规则不向 Web 客户端返回。
## M3:从单张到每周学习(第 5 个迭代起)
- [ ] 定义孩子档案:昵称、年龄、学习目标;多孩支持作为后续增强。
- [ ] 设计学习包:年龄、目标、每日时长、天数、每天的练习组合。
- [ ] 实现单张替换、整包收藏、导出进度与“已完成”打卡。
- [ ] 定义推荐去重和难度分配规则,避免连续出现相同题型。
- [ ] 以轻量周进度和连续天数做回访,不做高负担的学习报告。
### 完成标准
- 用户可创建一套至少 5 天的练习安排。
- 创建后可完成至少“替换一张、导出一张、标记完成”三项动作。
- 能统计创建包后导出 3 张以上的比例。
## M4:Web 试点(与 M2 并行,受服务端样板依赖)
- [ ] 完成服务端 Render API 的认证、任务与文件交付能力。
- [ ] 搭建一个小型 Web 站点:首页、主题聚合页、详情/生成页、打印/PDF 下载页。
- [ ] 只上线 10–20 个高意图内容页和 2–3 类服务端生成器。
- [ ] 建立 Web 独立埋点:搜索来源、关键词、预览、生成、下载、回访。
- [ ] 制定 SEO 内容模板与发布流程,确保每新增内容具有独立标题、描述、主题和相关推荐。
### 完成标准
- Web 能完成一次独立于小程序的访问、生成和下载。
- 浏览器不包含核心生成规则、完整模板坐标或原始线稿素材。
- 先获得自然搜索和下载数据,再决定是否扩成完整资源平台。
## 决策检查点
| 检查点 | 决策问题 | 继续投入条件 |
| --- | --- | --- |
| 涂色 MVP 上线后 | 是否扩大内容库和主题包? | 预览→导出、收藏或分享表现显著高于现有题型中位数 |
| 学习包试点后 | 是否做 PDF/会员? | 用户确实会连续导出多张,且有明确的批量打印需求 |
| Web 试点后 | 是否建设完整 Web 平台? | 自然流量、下载和回访有增长;内容可稳定周更 |
| 服务端渲染试点后 | 是否迁移更多旧题型? | P95 生成时延、成功率和单位生成成本满足目标 |
## 风险清单
| 风险 | 应对 |
| --- | --- |
| 涂色素材版权不清晰 | 建立授权台账;不使用知名 IP;发布前人工审核 |
| 服务端生成成本失控 | 参数哈希缓存、热门主题预生成、限额与异步任务 |
| Web 内容薄、没有搜索价值 | 先做少量高意图页面,不以“站点页面数”作为目标 |
| 小程序和 Web 各自维护内容 | 以 `worksheetId` 和平台无关元数据作为单一内容源 |
| 过早会员化影响首次体验 | 先验证学习包和批量下载需求;单张基础内容保持可试用 |
+2
View File
@@ -1,5 +1,7 @@
# Doodle Mini — 后端部署方案(NestJS + 自有云服务器)
> **2026-10-12 决策更新**:已确认迁移到自有腾讯云服务器。当前实施请使用 [云服务端迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md)。本文保留为历史技术预案,其中版本、数据模型与本地文件方案不可直接当作当前实施规格;“零增量成本/零成本 CDN”不作为成本事实。
> 版本:v1.0
> 最后更新:2026-03-27
> **定位**:**远期 / 阶段到期后的迁移方案**(自有服务器 + NestJS)。
@@ -1,5 +1,7 @@
# Doodle Mini — 小程序云开发方案
> **2026-10-12 决策更新**:本文用于了解现网 CloudBase 和历史数据设计。已确认迁移到自有云服务器,实施以 [云服务端迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md) 为准。迁移集合和字段以实际代码及线上盘点为准,不直接按本文的设计集合导入。
> 版本:v1.1
> 最后更新:2026-03-27
> **适用范围**:与 [技术架构设计文档](./技术架构设计文档.md) 配套;**当前阶段**采用本文档所述微信云开发环境实现后端能力。
+1 -1
View File
@@ -4,7 +4,7 @@
> 最后更新:2026-03-27
> 配套文档:[现有功能清单](./现有功能清单.md) | [产品设计文档](./产品设计文档.md) | [小程序云开发方案](./小程序云开发方案.md) | [后端部署方案](./后端部署方案.md) | [PC-Web端技术预案](./PC-Web端技术预案.md)
**后端阶段说明(重要)**:**现阶段**后端能力采用**微信小程序云开发**(云数据库、云存储、云函数),与 [小程序云开发方案](./小程序云开发方案.md) 一致。**当前阶段有效期至 2026 年 9 月 15 日**(到期前需结合用量、成本与产品路线,决定续用云开发、升级套餐或迁移至 [后端部署方案](./后端部署方案.md) 所述 NestJS + 自有服务器方案)。前端仍通过 `platform/cloud-adapter` 对接 `wx.cloud`,迁移时再抽象为 HTTP 适配层。
**后端阶段说明(2026-10-12 更新)**:现网仍使用微信云开发,已确认迁移到自有腾讯云服务器 + 统一 API + MySQL + 七牛。实施、兼容与退场以 [云服务端迁移方案(doodle-store)](../../doodle-store/docs/云服务端迁移方案.md) 为准;下文旧阶段、免费额度及未来方案描述作为历史设计参考。当前云调用散落在多个工具和页面,不能仅修改 `platform/cloud-adapter` 完成迁移。存量小程序 Canvas 绘制与本次业务后端迁移分别实施。
---
+109 -9
View File
@@ -85,6 +85,7 @@ type SharePathData = {
functionId?: string;
worksheetId?: string;
currentMode?: string;
pageTitle?: string;
};
type SharePathPage = {
@@ -143,9 +144,13 @@ export function getAppShareAppMessage(
trackerLabel?: string,
page?: SharePathPage,
) {
if (trackerLabel) {
tracker.reportShare(trackerLabel);
}
tracker.reportShare(page?.route || trackerLabel || '', {
...(trackerLabel && { worksheet_title: trackerLabel }),
...(page?.data?.functionId || page?.data?.worksheetId
? { worksheet_id: page.data.functionId || page.data.worksheetId }
: {}),
...(page?.data?.currentMode ? { mode: page.data.currentMode } : {}),
});
return {
title: defaultShareConfig.title,
path: buildPageSharePath(page),
@@ -158,9 +163,13 @@ export function getAppShareTimeline(
trackerLabel?: string,
page?: SharePathPage,
) {
if (trackerLabel) {
tracker.reportShare(trackerLabel);
}
tracker.reportShare(page?.route || trackerLabel || '', {
...(trackerLabel && { worksheet_title: trackerLabel }),
...(page?.data?.functionId || page?.data?.worksheetId
? { worksheet_id: page.data.functionId || page.data.worksheetId }
: {}),
...(page?.data?.currentMode ? { mode: page.data.currentMode } : {}),
});
return {
title: defaultShareConfig.title,
query: buildPageShareTimelineQuery(page),
@@ -171,10 +180,10 @@ export function getAppShareTimeline(
/** 供 Page({}) 直接展开:统一转发 / 分享到朋友圈(path 为当前页) */
export const appSharePageMethods = {
onShareAppMessage(this: SharePathPage) {
return getAppShareAppMessage(undefined, this);
return getAppShareAppMessage(this.data?.pageTitle, this);
},
onShareTimeline(this: SharePathPage) {
return getAppShareTimeline(undefined, this);
return getAppShareTimeline(this.data?.pageTitle, this);
},
};
@@ -331,6 +340,7 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
errorToast: '请先生成内容',
trackerName: this.data.pageTitle,
trackerMode: this.data.currentMode,
trackerPage: this.route,
worksheetId,
};
@@ -394,6 +404,7 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
errorToast: '请先生成内容',
trackerName: this.data.pageTitle,
trackerMode: this.data.currentMode,
trackerPage: this.route,
worksheetId,
});
}
@@ -591,6 +602,11 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
});
wx.setNavigationBarTitle({ title });
tracker.reportPreviewView(this.route || '', {
worksheet_id: functionId,
worksheet_title: title,
...(this.data.currentMode ? { mode: this.data.currentMode } : {}),
});
},
};
}
@@ -607,6 +623,90 @@ export function applyPageMixin(
config: PageCommonMethodsConfig = {},
) {
const commonMethods = getPageCommonMethods(config);
const trackedOptions = { ...pageOptions };
const generationStartKey = '__generationTrackingStartedAt';
const beginGenerationTracking = function (this: PageCanvasInstance) {
if ((this as any)[generationStartKey]) return;
const worksheetId =
typeof this.getWorksheetStatsId === 'function'
? this.getWorksheetStatsId()
: this.data.functionId;
(this as any)[generationStartKey] = Date.now();
tracker.reportGenerateStart(this.route || '', {
worksheet_id: worksheetId || '',
...(this.data.currentMode ? { mode: this.data.currentMode } : {}),
});
};
const originalOnRandom = pageOptions.onRandom;
if (typeof originalOnRandom === 'function') {
trackedOptions.onRandom = function (
this: PageCanvasInstance,
...args: any[]
) {
beginGenerationTracking.call(this);
return originalOnRandom.apply(this, args);
};
}
const originalOnPreviewRefresh = pageOptions.onPreviewRefresh;
if (typeof originalOnPreviewRefresh === 'function') {
trackedOptions.onPreviewRefresh = function (
this: PageCanvasInstance,
...args: any[]
) {
beginGenerationTracking.call(this);
return originalOnPreviewRefresh.apply(this, args);
};
}
const originalDrawCanvas = pageOptions.drawCanvas;
if (typeof originalDrawCanvas === 'function') {
trackedOptions.drawCanvas = async function (
this: PageCanvasInstance,
...args: any[]
) {
try {
const result = await originalDrawCanvas.apply(this, args);
const startedAt = (this as any)[generationStartKey] as
| number
| undefined;
if (startedAt) {
delete (this as any)[generationStartKey];
const worksheetId =
typeof this.getWorksheetStatsId === 'function'
? this.getWorksheetStatsId()
: this.data.functionId;
tracker.reportGenerateResult(
this.route || '',
this.data.hasContent !== false,
{
worksheet_id: worksheetId || '',
duration_ms: Date.now() - startedAt,
...(this.data.currentMode
? { mode: this.data.currentMode }
: {}),
},
);
}
return result;
} catch (error) {
const startedAt = (this as any)[generationStartKey] as
| number
| undefined;
if (startedAt) {
delete (this as any)[generationStartKey];
tracker.reportGenerateResult(this.route || '', false, {
worksheet_id: this.data.functionId || '',
duration_ms: Date.now() - startedAt,
fail_reason: 'draw_exception',
});
}
throw error;
}
};
}
// 提取 pageOptions 中的 data(如果有)
const pageData = pageOptions.data || {};
@@ -615,7 +715,7 @@ export function applyPageMixin(
// 注意:pageOptions 放在后面,这样页面可以覆盖公共方法
const mergedOptions: any = {
...commonMethods,
...pageOptions,
...trackedOptions,
// 处理 data 的合并(需要特殊处理,避免覆盖)
data: {
...pageData,
@@ -55,6 +55,15 @@ createFocusPage({
onLoad(options: { id?: string; mode?: string }) {
const routeId = options.id || FOCUS_TYPE_CONFIGS[0].id;
// “识别形状”仍由独立页面承载。兼容已发布的旧路径和本地兜底
// /focusPages/focusDraw/focusDraw?id=shape-recognition,避免通用注册表
// 找不到该 id 时静默回退为第一个题型。
if (routeId === 'shape-recognition') {
wx.redirectTo({ url: '/focusPages/shape/shape' });
return;
}
const result = findTypeByRouteId(routeId);
if (!result) return;
+19
View File
@@ -10,6 +10,7 @@ import {
shouldShowShareGuide,
shouldUnlockWithAd,
} from '../../utils/shareGuide';
import tracker from '../../utils/tracker';
Page({
canvas: null as Canvas | null,
@@ -20,6 +21,8 @@ Page({
timer: null as any,
data: {
worksheetId: 'shape-recognition',
pageTitle: '识别图形,涂一涂',
shapeList: [] as ShapeCard[],
showSelectShapePopup: false,
selectedShapeIndex: -1,
@@ -31,6 +34,10 @@ Page({
},
onLoad() {
tracker.reportPreviewView('focusPages/shape/shape', {
worksheet_id: 'shape-recognition',
worksheet_title: '识别图形,涂一涂',
});
// 判断是否在PC端开发工具上运行
// const systemInfo = wx.getDeviceInfo();
// if (systemInfo.platform === 'devtools') {
@@ -213,6 +220,10 @@ Page({
* 随机生成6个图形
*/
refreshShapeCard() {
const startedAt = Date.now();
tracker.reportGenerateStart('focusPages/shape/shape', {
worksheet_id: 'shape-recognition',
});
// 随机取6个图形
const shapesCopy = [...SHAPES];
for (let i = shapesCopy.length - 1; i > 0; i--) {
@@ -244,6 +255,10 @@ Page({
() => {
// 绘制canvas
this.drawCanvas();
tracker.reportGenerateResult('focusPages/shape/shape', true, {
worksheet_id: 'shape-recognition',
duration_ms: Date.now() - startedAt,
});
},
);
},
@@ -312,6 +327,8 @@ Page({
await downloadPrint(this.canvas, {
errorToast: '请先生成图形',
trackerName: '图形涂色',
trackerPage: 'focusPages/shape/shape',
worksheetId: 'shape-recognition',
});
},
@@ -327,6 +344,8 @@ Page({
await downloadPrint(this.canvas, {
errorToast: '请先生成图形',
trackerName: '图形涂色',
trackerPage: 'focusPages/shape/shape',
worksheetId: 'shape-recognition',
});
}
},
+1 -1
View File
@@ -443,7 +443,7 @@ const CATEGORY_ITEMS_BY_ID: Record<string, CategoryItem[]> = {
previewImg: '/assets/entrancePicture/focus/shape-recognition.png',
ageBand: age(3, 5),
difficulty: 1,
path: '/focusPages/focusDraw/focusDraw?id=shape-recognition',
path: '/focusPages/shape/shape',
available: true,
}),
item({
+13 -1
View File
@@ -1,6 +1,7 @@
import { appSharePageMethods } from '../../base/pageMixin';
import { CATEGORY_LIST_WITH_ALL } from '../../core/data/categories';
import { takePendingCategoryTabId } from '../../utils/index';
import { parseMiniProgramUrl, takePendingCategoryTabId } from '../../utils/index';
import tracker from '../../utils/tracker';
import { CATEGORY_DATA, type CategoryItem } from './category.data';
type CategoryTab = {
@@ -224,6 +225,7 @@ Page({
_defaultCategoryId: 'all',
onLoad(options: Record<string, string>) {
tracker.reportPageView('pages/category/category');
if (options.id && options.id !== 'all') {
this._defaultCategoryId = options.id;
this.setData({ activeCategoryId: options.id });
@@ -285,6 +287,9 @@ Page({
displayItems: filtered,
scrollIntoViewId: '',
});
tracker.report('category_select', 'pages/category/category', {
category_id: id,
});
},
onSearchInput(e: WechatMiniprogram.Input) {
@@ -319,6 +324,13 @@ Page({
return;
}
tracker.reportWorksheetClick('pages/category/category', {
worksheet_id: String(parseMiniProgramUrl(path).query.id || ''),
worksheet_title: title || '',
category_id: this.data.activeCategoryId,
source: 'category_list',
});
if (TAB_BAR_PATHS.has(path)) {
wx.switchTab({ url: path });
return;
+22
View File
@@ -4,6 +4,7 @@ import {
parseMiniProgramUrl,
stashPendingCategoryTabId,
} from '../../utils/index';
import tracker from '../../utils/tracker';
import {
HOME_DAILY_CHECKIN_ITEMS,
HOME_NOTICES,
@@ -124,6 +125,23 @@ function navigateByPath(path?: string, fallbackTitle?: string) {
});
}
function worksheetIdFromPath(path?: string): string {
if (!path) return '';
return String(parseMiniProgramUrl(path).query.id || '');
}
function trackWorksheetClick(
path: string | undefined,
title: string | undefined,
source: string,
) {
tracker.reportWorksheetClick('pages/home/home', {
worksheet_id: worksheetIdFromPath(path),
worksheet_title: title || '',
source,
});
}
// ── Skeleton data ──
function skeletonItem(id: string): HomeDisplayItemView {
@@ -196,6 +214,7 @@ Page({
},
onLoad() {
tracker.reportPageView('pages/home/home');
void this.initData();
},
@@ -274,12 +293,14 @@ Page({
onTapFeatured(e: WechatMiniprogram.TouchEvent) {
const path = e.currentTarget.dataset.path as string | undefined;
const title = e.currentTarget.dataset.title as string | undefined;
trackWorksheetClick(path, title, 'home_featured');
navigateByPath(path, title);
},
onTapHotRecommend(e: WechatMiniprogram.TouchEvent) {
const path = e.currentTarget.dataset.path as string | undefined;
const title = e.currentTarget.dataset.title as string | undefined;
trackWorksheetClick(path, title, 'home_hot');
navigateByPath(path, title);
},
@@ -310,6 +331,7 @@ Page({
const title =
detail.title ||
(e.currentTarget.dataset.title as string | undefined);
trackWorksheetClick(path, title, 'home_section');
navigateByPath(path, title);
},
@@ -23,8 +23,12 @@ Page({
return;
}
// 统一跳转到 focusDraw 页面
// “识别形状”仍使用独立页面;其余题型统一跳转到 focusDraw 页面
if (functionItem.page) {
if (functionId === 'shape-recognition') {
wx.navigateTo({ url: '/focusPages/shape/shape' });
return;
}
let url = `/focusPages/focusDraw/focusDraw?id=${functionId}`;
if (functionItem.mode) {
url += `&mode=${functionItem.mode}`;
@@ -23,6 +23,6 @@ export const UNRELEASED_PAGE_ENTRIES: UnreleasedPageEntry[] = [
id: 'clock-connect',
title: '时钟连线',
subtitle: '看钟表连对应时间,认识时刻',
path: '/mathPages/clockConnect/clockConnect',
path: '/mathPages/clockReading/clockReading?id=clock-connect',
},
];
+6 -2
View File
@@ -4,6 +4,7 @@ export interface DownloadLogRecord {
_id: string;
userId: string;
worksheetId: string;
exportId?: string;
createdAt: string;
worksheet?: {
_id: string;
@@ -22,13 +23,16 @@ export interface DownloadLogRecord {
/**
* 记录下载日志
*/
export async function addDownloadLog(worksheetId: string): Promise<boolean> {
export async function addDownloadLog(
worksheetId: string,
exportId?: string,
): Promise<boolean> {
if (!worksheetId) return false;
try {
await getUser();
const res = await wx.cloud.callFunction({
name: 'userDownloadLogs',
data: { action: 'add', worksheetId },
data: { action: 'add', worksheetId, exportId },
});
const result = (res?.result as any) || {};
return result.code === 0;
+20 -9
View File
@@ -6,6 +6,7 @@
import { checkAndSaveImage } from './saveImage';
import tracker from './tracker';
import { addDownloadLog } from './downloadLogs';
import { generateUUID } from './uuid';
import {
getUnlockMethod,
isDownloadLimitEnabled,
@@ -131,23 +132,32 @@ function doDownload(
errorToast?: string;
trackerName?: string;
trackerMode?: string;
trackerPage?: string;
worksheetId?: string;
},
): Promise<boolean> {
if (options.trackerName) {
if (options.trackerMode) {
tracker.reportDownload(options.trackerName, options.trackerMode);
} else {
tracker.reportDownload(options.trackerName);
}
}
const exportId = generateUUID();
const trackerPage = options.trackerPage || options.trackerName || '';
const trackerParams = {
export_id: exportId,
...(options.worksheetId && { worksheet_id: options.worksheetId }),
...(options.trackerName && { worksheet_title: options.trackerName }),
...(options.trackerMode && { mode: options.trackerMode }),
};
return checkAndSaveImage(canvas).then((saved) => {
// 用户点击保存不等于保存成功,二者必须分开统计。
tracker.reportExportClick(trackerPage, trackerParams);
return checkAndSaveImage(canvas).then(async (saved) => {
if (saved) {
// 只在 wx.saveImageToPhotosAlbum 的 success 回调后进入此分支。
recordDownloadSuccess();
tracker.reportExportResult(trackerPage, true, trackerParams);
if (options.worksheetId) {
addDownloadLog(options.worksheetId);
await addDownloadLog(options.worksheetId, exportId);
}
} else {
tracker.reportExportResult(trackerPage, false, trackerParams);
}
return saved;
});
@@ -163,6 +173,7 @@ export async function downloadPrint(
errorToast?: string;
trackerName?: string;
trackerMode?: string;
trackerPage?: string;
worksheetId?: string;
} = {},
): Promise<boolean> {
+14 -3
View File
@@ -1,4 +1,10 @@
import { getUser } from './auth';
import tracker from './tracker';
function getCurrentPageRoute(): string {
const pages = getCurrentPages();
return pages[pages.length - 1]?.route || '';
}
export interface FavoriteRecord {
_id: string;
@@ -31,7 +37,9 @@ export async function addFavorite(worksheetId: string): Promise<boolean> {
data: { action: 'add', worksheetId },
});
const result = (res?.result as any) || {};
return result.code === 0;
const success = result.code === 0;
if (success) tracker.reportFavorite(getCurrentPageRoute(), worksheetId, true);
return success;
} catch (e) {
console.error('addFavorite failed', e);
return false;
@@ -49,7 +57,11 @@ export async function removeFavorite(worksheetId: string): Promise<boolean> {
data: { action: 'remove', worksheetId },
});
const result = (res?.result as any) || {};
return result.code === 0;
const success = result.code === 0;
if (success) {
tracker.reportFavorite(getCurrentPageRoute(), worksheetId, false);
}
return success;
} catch (e) {
console.error('removeFavorite failed', e);
return false;
@@ -133,4 +145,3 @@ export async function batchCheckFavorited(
return {};
}
}
+88 -17
View File
@@ -115,18 +115,96 @@ class Tracker {
};
}
/**
* 统一业务埋点入口。pageName 使用页面 route,内容使用稳定的 worksheet_id。
*/
report(
eventName: string,
pageName: string,
extraParams?: Record<string, any>,
): void {
try {
this.sendEvent(
eventName,
this.getCommonEventParams(eventName, pageName, extraParams),
);
} catch (error) {
console.error(`上报 ${eventName} 事件失败:`, error);
}
}
reportPageView(pageName: string): void {
this.report('page_view', pageName);
}
reportWorksheetClick(
pageName: string,
extraParams: Record<string, any>,
): void {
this.report('worksheet_click', pageName, extraParams);
}
reportPreviewView(
pageName: string,
extraParams: Record<string, any>,
): void {
this.report('preview_view', pageName, extraParams);
}
reportGenerateStart(
pageName: string,
extraParams: Record<string, any>,
): void {
this.report('generate_start', pageName, extraParams);
}
reportGenerateResult(
pageName: string,
success: boolean,
extraParams: Record<string, any>,
): void {
this.report(
success ? 'generate_success' : 'generate_fail',
pageName,
extraParams,
);
}
reportExportClick(
pageName: string,
extraParams: Record<string, any>,
): void {
this.report('export_click', pageName, extraParams);
}
reportExportResult(
pageName: string,
success: boolean,
extraParams: Record<string, any>,
): void {
this.report(
success ? 'export_success' : 'export_fail',
pageName,
extraParams,
);
}
reportFavorite(
pageName: string,
worksheetId: string,
favorited: boolean,
): void {
this.report(favorited ? 'favorite_add' : 'favorite_remove', pageName, {
worksheet_id: worksheetId,
});
}
/**
* 上报分享点击事件
* @param pageName 页面名称
*/
reportShare(pageName: string): void {
try {
const params = this.getCommonEventParams('share_click', pageName);
this.sendEvent('share_click', params);
} catch (error) {
console.error('上报分享事件失败:', error);
}
reportShare(pageName: string, extraParams?: Record<string, any>): void {
this.report('share_click', pageName, extraParams);
}
/**
@@ -135,15 +213,8 @@ class Tracker {
* @param mode 模式(可选)
*/
reportDownload(pageName: string, mode?: string): void {
try {
const params = this.getCommonEventParams('download', pageName, {
...(mode && { mode }),
});
this.sendEvent('download', params);
} catch (error) {
console.error('上报下载事件失败:', error);
}
// 兼容旧调用;新代码请使用 reportExportClick / reportExportResult。
this.reportExportClick(pageName, { ...(mode && { mode }) });
}
/**