diff --git a/cloudfunctions/ageRecommendedQuery/index.js b/cloudfunctions/ageRecommendedQuery/index.js index 1278f31..79cb84b 100644 --- a/cloudfunctions/ageRecommendedQuery/index.js +++ b/cloudfunctions/ageRecommendedQuery/index.js @@ -35,6 +35,13 @@ function difficultyLabel(difficulty) { return DIFFICULTY_LABELS[difficulty] || '基础'; } +function mergeSeedStats(items) { + for (const item of items) { + item.likes = (item.likes || 0) + (item.likes_seed || 0); + item.downloads = (item.downloads || 0) + (item.downloads_seed || 0); + } +} + function toRecommendedItem(ws) { return { worksheetId: ws._id, @@ -118,6 +125,7 @@ exports.main = async (event) => { const band = AGE_BANDS[ageKey]; const db = cloud.database(); const activeWorksheets = await queryActiveWorksheets(db); + mergeSeedStats(activeWorksheets); const matchedWorksheets = activeWorksheets.filter((ws) => coversAgeBand(ws, band), ); diff --git a/cloudfunctions/homeAutoRefresh/index.js b/cloudfunctions/homeAutoRefresh/index.js index 31930cc..dd3fb96 100644 --- a/cloudfunctions/homeAutoRefresh/index.js +++ b/cloudfunctions/homeAutoRefresh/index.js @@ -15,6 +15,13 @@ function ageBand(min, max) { return `${min}-${max}岁`; } +function mergeSeedStats(items) { + for (const item of items) { + item.likes = (item.likes || 0) + (item.likes_seed || 0); + item.downloads = (item.downloads || 0) + (item.downloads_seed || 0); + } +} + function toHomeDisplayItem(ws) { return { id: ws._id, @@ -87,21 +94,21 @@ exports.main = async () => { return { success: true, message: '定时任务已暂停,跳过执行' }; } - // Query top 5 by downloads - const { data: topDownloads } = await db + const { data: allActive } = await db .collection('worksheets') .where({ status: 'active' }) - .orderBy('downloads', 'desc') - .limit(5) + .limit(100) .get(); - // Query top 5 by likes - const { data: topLikes } = await db - .collection('worksheets') - .where({ status: 'active' }) - .orderBy('likes', 'desc') - .limit(5) - .get(); + mergeSeedStats(allActive); + + const topDownloads = [...allActive] + .sort((a, b) => (b.downloads || 0) - (a.downloads || 0)) + .slice(0, 5); + + const topLikes = [...allActive] + .sort((a, b) => (b.likes || 0) - (a.likes || 0)) + .slice(0, 5); const featured = topDownloads.map(toHomeDisplayItem); const hot = topLikes.map(toHomeDisplayItem); diff --git a/cloudfunctions/pageContentBuild/index.js b/cloudfunctions/pageContentBuild/index.js index cc8327d..e747021 100644 --- a/cloudfunctions/pageContentBuild/index.js +++ b/cloudfunctions/pageContentBuild/index.js @@ -28,8 +28,8 @@ function toDisplayItem(ws) { difficultyLabel: DIFFICULTY_LABELS[ws.difficulty] || '基础', path: ws.path || '', available: true, - likes: ws.likes || 0, - downloads: ws.downloads || 0, + likes: (ws.likes || 0) + (ws.likes_seed || 0), + downloads: (ws.downloads || 0) + (ws.downloads_seed || 0), date: ws.updatedAt ? new Date(ws.updatedAt).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10), diff --git a/cloudfunctions/worksheetsBatchPatch/index.js b/cloudfunctions/worksheetsBatchPatch/index.js index f9b18e5..bd05b00 100644 --- a/cloudfunctions/worksheetsBatchPatch/index.js +++ b/cloudfunctions/worksheetsBatchPatch/index.js @@ -74,11 +74,40 @@ function buildUpdateData(db, patch) { hasField = true; } + if (patch.likes_seed !== undefined) { + const v = toInt(patch.likes_seed, -1); + if (v < 0) return { id, ok: false, error: 'likes_seed 须为非负整数' }; + data.likes_seed = v; + hasField = true; + } + + if (patch.downloads_seed !== undefined) { + const v = toInt(patch.downloads_seed, -1); + if (v < 0) + return { id, ok: false, error: 'downloads_seed 须为非负整数' }; + data.downloads_seed = v; + hasField = true; + } + + if (patch.likes !== undefined) { + const v = toInt(patch.likes, -1); + if (v < 0) return { id, ok: false, error: 'likes 须为非负整数' }; + data.likes = v; + hasField = true; + } + + if (patch.downloads !== undefined) { + const v = toInt(patch.downloads, -1); + if (v < 0) return { id, ok: false, error: 'downloads 须为非负整数' }; + data.downloads = v; + hasField = true; + } + if (!hasField) { return { id, ok: false, - error: '未包含可更新字段(ageMin/ageMax 或 tags)', + error: '未包含可更新字段(ageMin/ageMax、tags、seed 或 stats)', }; } diff --git a/cloudfunctions/worksheetsQuery/index.js b/cloudfunctions/worksheetsQuery/index.js index 6b5684d..fdbfd14 100644 --- a/cloudfunctions/worksheetsQuery/index.js +++ b/cloudfunctions/worksheetsQuery/index.js @@ -5,6 +5,13 @@ cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); const VALID_STATUS = new Set(['draft', 'active', 'hidden']); const STATUS_ORDER = { draft: 0, hidden: 1, active: 2 }; +function mergeSeedStats(items) { + for (const item of items) { + item.likes = (item.likes || 0) + (item.likes_seed || 0); + item.downloads = (item.downloads || 0) + (item.downloads_seed || 0); + } +} + exports.main = async (event) => { try { const db = cloud.database(); @@ -54,6 +61,8 @@ exports.main = async (event) => { else if (item.status === 'hidden') stats.hidden++; } + mergeSeedStats(data); + return { success: true, data, diff --git a/docs/展示基线方案.md b/docs/展示基线方案.md new file mode 100644 index 0000000..651d25e --- /dev/null +++ b/docs/展示基线方案.md @@ -0,0 +1,83 @@ +# 展示基线方案(Display Seed Baseline) + +## 背景 + +worksheet 的 `likes`(收藏)和 `downloads`(下载)初始值为 0,对用户缺乏吸引力。 +之前的做法是直接修改数据库真实字段,导致无法区分哪些是运营配置的底数、哪些是真实用户行为。 + +## 方案概述 + +**数据层拆开:真实 + 展示基线。** + +| 字段 | 含义 | 谁在写 | 初始值 | +|------|------|--------|--------| +| `likes` | 真实用户收藏累加 | 云函数 `worksheetsStatsUpdate` / `userFavorites` 的 `inc(1)` | 0 | +| `downloads` | 真实用户下载累加 | 云函数 `worksheetsStatsUpdate` / `userDownloadLogs` 的 `inc(1)` | 0 | +| `likes_seed` | 运营配置的收藏基线 | worksheetSync 页面一键同步 | 0 | +| `downloads_seed` | 运营配置的下载基线 | worksheetSync 页面一键同步 | 0 | + +**展示值** = `likes + likes_seed`(或 `downloads + downloads_seed`) + +**运营看板** 只看 `likes` / `downloads`(真实值),不受 seed 污染。 + +## 涉及改动 + +### 1. 数据库 `worksheets` 集合 + +每条 worksheet 新增两个字段: + +```json +{ + "likes_seed": 20, + "downloads_seed": 15 +} +``` + +### 2. Seed 数据来源 + +文件路径:`miniprogram/supportPages/worksheetSync/mockLikes.ts` + +导出 `MOCK_SEED_COUNTS`,以 worksheet id 为 key,值为 `{ downloads, likes }`。 + +### 3. worksheetSync 管理页 + +新增「同步 Seed 数据」按钮,点击后: + +1. 读取 `MOCK_SEED_COUNTS` +2. 构建 patches:`[{ id, likes_seed, downloads_seed }, ...]` +3. 调用 `worksheetsBatchPatch` 批量写入 + +### 4. 云函数改动 + +所有对外返回 `likes` / `downloads` 的云函数,在返回前合并 seed 值: + +```js +item.likes = (item.likes || 0) + (item.likes_seed || 0); +item.downloads = (item.downloads || 0) + (item.downloads_seed || 0); +``` + +涉及的云函数: + +| 云函数 | 改动点 | +|--------|--------| +| `worksheetsQuery` | 返回数据前合并 seed | +| `ageRecommendedQuery` | 合并 seed 后再排序和取 Top N | +| `homeAutoRefresh` | 改为全量查询 + 合并 seed + 内存排序 | +| `pageContentBuild` | `toDisplayItem` 中合并 seed | + +### 5. 不需要改动的部分 + +| 模块 | 原因 | +|------|------| +| `worksheetsStatsUpdate` | 只 `inc(1)` 真实字段,不涉及 seed | +| `userDownloadLogs` | 只 `inc(1)` downloads,不涉及 seed | +| `userFavorites` | 只 `inc(1)` likes,不涉及 seed | +| `worksheetsPublish` | 发布时设置真实初始值,不涉及 seed | +| 用户端收藏/下载交互 | 保持现有行为不变 | + +## 优势 + +1. **真实数据干净**:`likes` / `downloads` 永远只反映真实用户行为 +2. **全站一致**:所有出口在云端统一计算展示值,端上无需额外逻辑 +3. **调整无需发版**:修改 seed 后通过管理页一键同步,不用重新构建小程序 +4. **运营可判断**:直接查 DB 的 `likes` / `downloads` 字段即为真实数据 diff --git a/miniprogram/app.json b/miniprogram/app.json index 903dfab..9cbcb8c 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -18,7 +18,8 @@ "categoryContentManage/categoryContentManage", "homeContentManage/homeContentManage", "mathIndex/mathIndex", - "focusIndex/focusIndex" + "focusIndex/focusIndex", + "worksheetSync/worksheetSync" ], "independent": false }, diff --git a/miniprogram/pages/category/category.ts b/miniprogram/pages/category/category.ts index 99f24a0..a392266 100644 --- a/miniprogram/pages/category/category.ts +++ b/miniprogram/pages/category/category.ts @@ -49,6 +49,38 @@ const TAB_BAR_PATHS = new Set([ // Page-level mutable state (shared across methods) let _categoryData: CategoryDataset | null = null; +// --- 以下仅为本地调试:拉取分类数据后查看各 worksheet 的 downloads / likes,非业务逻辑 --- + +function debugLogWorksheetStatsAfterCategoryLoad( + source: + | { kind: 'cloud'; rows: Record[] } + | { kind: 'static'; data: CategoryDataset }, +) { + let map: Record = {}; + if (source.kind === 'cloud') { + for (const raw of source.rows) { + const id = String(raw._id || '').trim(); + if (!id) continue; + map[id] = { + downloads: Number(raw.downloads) || 0, + likes: Number(raw.likes) || 0, + }; + } + } else { + for (const group of source.data.categories) { + for (const item of group.items) { + map[item.id] = { + downloads: Number(item.downloads) || 0, + likes: Number(item.likes) || 0, + }; + } + } + } + console.log('worksheetStatsById', JSON.stringify(map, null, 2)); +} + +// --- 调试块结束 --- + /** 将云端 worksheet 原始数据转换为 CategoryItem */ function toDisplayItem(raw: Record): CategoryItem { const difficulty = (Number(raw.difficulty) || 2) as 1 | 2 | 3 | 4; @@ -129,12 +161,16 @@ const DYNAMIC_LIMIT = 12; function buildHotItems(): CategoryItem[] { const all = buildAllItems(); - return [...all].sort((a, b) => (b.likes || 0) - (a.likes || 0)).slice(0, DYNAMIC_LIMIT); + return [...all] + .sort((a, b) => (b.likes || 0) - (a.likes || 0)) + .slice(0, DYNAMIC_LIMIT); } function buildTopDownloadsItems(): CategoryItem[] { const all = buildAllItems(); - return [...all].sort((a, b) => (b.downloads || 0) - (a.downloads || 0)).slice(0, DYNAMIC_LIMIT); + return [...all] + .sort((a, b) => (b.downloads || 0) - (a.downloads || 0)) + .slice(0, DYNAMIC_LIMIT); } function getItemsByCategory(categoryId: string): CategoryItem[] { @@ -203,9 +239,15 @@ Page({ const result = (res?.result as any) || {}; if (!result.success) throw new Error(result.message || '查询失败'); - _categoryData = buildCategoryDataFromCloud(result.data || []); + const rows = result.data || []; + _categoryData = buildCategoryDataFromCloud(rows); + debugLogWorksheetStatsAfterCategoryLoad({ kind: 'cloud', rows }); } catch { _categoryData = CATEGORY_DATA; + debugLogWorksheetStatsAfterCategoryLoad({ + kind: 'static', + data: CATEGORY_DATA, + }); } const activeId = diff --git a/miniprogram/supportPages/worksheetSync/mockLikes.ts b/miniprogram/supportPages/worksheetSync/mockLikes.ts new file mode 100644 index 0000000..434816d --- /dev/null +++ b/miniprogram/supportPages/worksheetSync/mockLikes.ts @@ -0,0 +1,169 @@ +export const MOCK_SEED_COUNTS: Record< + string, + { downloads: number; likes: number } +> = { + 'word-coloring-grid': { + downloads: 0, + likes: 1, + }, + 'word-coloring-find': { + downloads: 0, + likes: 0, + }, + 'handwriting-sheet': { + downloads: 0, + likes: 0, + }, + 'letter-tracing-single': { + downloads: 10, + likes: 3, + }, + 'letter-tracing-two-column': { + downloads: 15, + likes: 13, + }, + 'letter-tracing-upper-lower': { + downloads: 3, + likes: 7, + }, + 'letter-tracing-case-pairing': { + downloads: 9, + likes: 9, + }, + 'letter-tracing-three': { + downloads: 12, + likes: 7, + }, + 'letter-tracing-half': { + downloads: 2, + likes: 1, + }, + 'letter-tracing-daily-checkin': { + downloads: 12, + likes: 14, + }, + 'number-find': { + downloads: 20, + likes: 23, + }, + 'number-coloring': { + downloads: 37, + likes: 41, + }, + 'counting-matching': { + downloads: 20, + likes: 28, + }, + 'number-object-match': { + downloads: 12, + likes: 17, + }, + 'counting-select': { + downloads: 10, + likes: 9, + }, + compare: { + downloads: 15, + likes: 10, + }, + 'number-sort': { + downloads: 35, + likes: 47, + }, + 'missing-number': { + downloads: 13, + likes: 10, + }, + 'number-decompose': { + downloads: 26, + likes: 21, + }, + 'number-decompose-20': { + downloads: 21, + likes: 18, + }, + 'one-digit-addition': { + downloads: 12, + likes: 15, + }, + 'addition-5': { + downloads: 11, + likes: 12, + }, + 'make-ten': { + downloads: 38, + likes: 35, + }, + 'break-ten': { + downloads: 0, + likes: 24, + }, + 'flat-ten': { + downloads: 26, + likes: 26, + }, + 'borrow-ten': { + downloads: 24, + likes: 12, + }, + 'practice-addition': { + downloads: 31, + likes: 21, + }, + 'practice-subtraction': { + downloads: 12, + likes: 14, + }, + 'practice-mixed': { + downloads: 22, + likes: 25, + }, + 'multiplication-table': { + downloads: 2, + likes: 5, + }, + 'color-shape-match': { + downloads: 22, + likes: 23, + }, + 'shape-symbol': { + downloads: 7, + likes: 7, + }, + 'position-coloring': { + downloads: 18, + likes: 20, + }, + 'color-pattern': { + downloads: 15, + likes: 15, + }, + 'match-connect': { + downloads: 10, + likes: 10, + }, + 'line-recognition': { + downloads: 20, + likes: 23, + }, + 'grid-reasoning': { + downloads: 17, + likes: 16, + }, + 'code-connect': { + downloads: 32, + likes: 29, + }, + 'dot-connect': { + downloads: 10, + likes: 20, + }, + 'grid-drawing-3x3': { + downloads: 20, + likes: 23, + }, + 'grid-drawing-5x5': { + downloads: 8, + likes: 12, + }, +}; diff --git a/miniprogram/supportPages/worksheetSync/worksheetSync.ts b/miniprogram/supportPages/worksheetSync/worksheetSync.ts index c1d8b5b..aa899f2 100644 --- a/miniprogram/supportPages/worksheetSync/worksheetSync.ts +++ b/miniprogram/supportPages/worksheetSync/worksheetSync.ts @@ -1,3 +1,5 @@ +import { MOCK_SEED_COUNTS } from './mockLikes'; + type CloudFunctionResult = { success?: boolean; message?: string; @@ -134,6 +136,8 @@ Page({ loadingAges: false, loadingTags: false, loadingRebuild: false, + loadingSeed: false, + loadingResetStats: false, statusText: '配置加载中,请稍候', }, @@ -312,4 +316,138 @@ Page({ this.setData({ loadingRebuild: false }); } }, + + async onSyncSeed() { + if ( + this.data.loadingAges || + this.data.loadingTags || + this.data.loadingRebuild || + this.data.loadingSeed + ) + return; + + this.setData({ loadingSeed: true }); + wx.showLoading({ title: '同步 Seed...' }); + const startedAt = Date.now(); + + try { + const patches = Object.entries(MOCK_SEED_COUNTS).map( + ([id, counts]) => ({ + id, + likes_seed: counts.likes, + downloads_seed: counts.downloads, + }), + ); + + const patchRes = await callCloudFunction('worksheetsBatchPatch', { + patches, + dryRun: false, + }); + + if (!patchRes.success) { + throw new Error(patchRes.message || '同步 Seed 失败'); + } + + const elapsed = Math.round((Date.now() - startedAt) / 1000); + + const text = [ + summarizeResult( + '同步 Seed(likes_seed / downloads_seed)', + patchRes, + ), + `共 ${patches.length} 条 Seed 数据`, + `耗时:${elapsed}s`, + ].join('\n'); + + this.setData({ statusText: text }); + wx.showToast({ title: '已完成', icon: 'success' }); + } catch (error) { + const msg = + error instanceof Error ? error.message : '同步 Seed 失败'; + this.setData({ statusText: msg }); + wx.showToast({ title: msg, icon: 'none' }); + } finally { + wx.hideLoading(); + this.setData({ loadingSeed: false }); + } + }, + + async onResetStats() { + if ( + this.data.loadingAges || + this.data.loadingTags || + this.data.loadingRebuild || + this.data.loadingSeed || + this.data.loadingResetStats + ) + return; + + const { confirm } = await wx.showModal({ + title: '确认初始化', + content: + '将所有 worksheet 的 likes 和 downloads 重置为 0,此操作不可撤销。确定继续?', + confirmText: '确定重置', + confirmColor: '#e53935', + }); + if (!confirm) return; + + this.setData({ loadingResetStats: true }); + wx.showLoading({ title: '重置中...' }); + const startedAt = Date.now(); + + try { + const queryRes = await callCloudFunction< + Array<{ _id: string }> + >('worksheetsQuery', {}); + + if (!queryRes.success || !queryRes.data) { + throw new Error(queryRes.message || '查询 worksheet 列表失败'); + } + + const patches = (queryRes.data as Array<{ _id: string }>).map( + (ws) => ({ + id: ws._id, + likes: 0, + downloads: 0, + }), + ); + + if (patches.length === 0) { + this.setData({ statusText: '没有需要重置的 worksheet' }); + wx.showToast({ title: '无数据', icon: 'none' }); + return; + } + + const patchRes = await callCloudFunction('worksheetsBatchPatch', { + patches, + dryRun: false, + }); + + if (!patchRes.success) { + throw new Error(patchRes.message || '重置失败'); + } + + const elapsed = Math.round((Date.now() - startedAt) / 1000); + + const text = [ + summarizeResult( + '初始化收藏和下载数据(likes / downloads → 0)', + patchRes, + ), + `共 ${patches.length} 条 worksheet`, + `耗时:${elapsed}s`, + ].join('\n'); + + this.setData({ statusText: text }); + wx.showToast({ title: '已重置', icon: 'success' }); + } catch (error) { + const msg = + error instanceof Error ? error.message : '重置失败'; + this.setData({ statusText: msg }); + wx.showToast({ title: msg, icon: 'none' }); + } finally { + wx.hideLoading(); + this.setData({ loadingResetStats: false }); + } + }, }); diff --git a/miniprogram/supportPages/worksheetSync/worksheetSync.wxml b/miniprogram/supportPages/worksheetSync/worksheetSync.wxml index 760a6f2..e0425a6 100644 --- a/miniprogram/supportPages/worksheetSync/worksheetSync.wxml +++ b/miniprogram/supportPages/worksheetSync/worksheetSync.wxml @@ -52,23 +52,37 @@ type="primary" width="100%" loading="{{loadingAges}}" - disabled="{{!configReady || loadingAges || loadingTags || loadingRebuild}}" + disabled="{{!configReady || loadingAges || loadingTags || loadingRebuild || loadingSeed || loadingResetStats}}" bindtap="onSyncAges"> {{loadingAges ? '同步中...' : '同步年龄'}} {{loadingTags ? '同步中...' : '同步标签'}} {{loadingRebuild ? '重建中...' : '重建配置'}} + + {{loadingSeed ? '同步中...' : '同步 Seed 数据'}} + + + {{loadingResetStats ? '重置中...' : '初始化收藏和下载数据'}} +