diff --git a/cloudfunctions/posts/index.js b/cloudfunctions/posts/index.js index 76b3125..5096cda 100644 --- a/cloudfunctions/posts/index.js +++ b/cloudfunctions/posts/index.js @@ -446,6 +446,7 @@ async function listComments(event, openid, isAdmin) { postId: post.id, status: 'visible' }) + .orderBy('createdAt', 'desc') .limit(MAX_COMMENTS_PER_POST) .get(); const comments = (result.data || []) diff --git a/harness/check-map-feed.mjs b/harness/check-map-feed.mjs index 9b35206..ecb4ff9 100644 --- a/harness/check-map-feed.mjs +++ b/harness/check-map-feed.mjs @@ -10,6 +10,11 @@ const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); const mapJs = readFileSync(join(rootDir, 'pages/map/map.js'), 'utf8'); const mapWxml = readFileSync(join(rootDir, 'pages/map/map.wxml'), 'utf8'); const mapWxss = readFileSync(join(rootDir, 'pages/map/map.wxss'), 'utf8'); +const discoverNearbyStart = mapJs.indexOf('async discoverNearby()'); +const focusPostStart = mapJs.indexOf('\n\n focusPost(', discoverNearbyStart); +assert.notEqual(discoverNearbyStart, -1, 'Map page should define discoverNearby.'); +assert.notEqual(focusPostStart, -1, 'Map page should define focusPost after discoverNearby.'); +const discoverNearbyBody = mapJs.slice(discoverNearbyStart, focusPostStart); const now = Date.now(); const posts = [ { @@ -135,5 +140,115 @@ assert.match( /launchFocus[\s\S]*?this\.setData\(\{[\s\S]*?showList:\s*launchFocus\.showList[\s\S]*?\},\s*\(\)\s*=>\s*\{[\s\S]*?this\.applyPostFilters\(posts,\s*'all',\s*null\);[\s\S]*?this\.hideDiagnostics\(\);[\s\S]*?\}\)/, 'Focused map launches should hide startup diagnostics immediately after the list and selected task are ready.' ); +assert.match( + discoverNearbyBody, + /async discoverNearby\(\) \{[\s\S]*?const post = nextCandidates\[Math\.floor\(Math\.random\(\) \* nextCandidates\.length\)\];[\s\S]*?selectedPost:\s*decorateMapPost\(post,\s*post\.id\),[\s\S]*?\}, \(\) => this\.refresh\(\)\);/, + 'Discovery should decorate only the selected post before showing the selected card.' +); +assert.doesNotMatch( + discoverNearbyBody, + /selectedPost:\s*post\s*[,}]/, + 'Discovery should not write a raw post into selectedPost while refresh is pending.' +); +assert.match( + mapJs, + /async buildPosts\(center\) \{[\s\S]*?return listPosts\(center,\s*\{ localOnly: true \}\);[\s\S]*?\}/, + 'buildPosts should keep raw derived posts and leave map decoration to list/preview builders.' +); +assert.doesNotMatch( + mapJs, + /buildPosts\(center\)[\s\S]*?\.map\(\(post\) => decorateMapPost\(post\)\)/, + 'buildPosts should not pre-decorate every post before filtering and preview building.' +); +assert.match( + mapJs, + /const viewportPosts = \[\];[\s\S]*?const categoryCounts = \{\};[\s\S]*?let openPostCount = 0;[\s\S]*?posts\.forEach\(\(post\) => \{[\s\S]*?if \(isOpenPost\(post\)\) \{[\s\S]*?openPostCount \+= 1;[\s\S]*?\}[\s\S]*?if \(!isPostInRegion\(post, mapRegion\)\) \{[\s\S]*?return;[\s\S]*?\}[\s\S]*?viewportPosts\.push\(post\);[\s\S]*?categoryCounts\[post\.category\]/, + 'Map filtering should collect viewport posts, category counts, and open-post count in a single pass.' +); +assert.doesNotMatch( + mapJs, + /openPostCount:\s*posts\.filter\(isOpenPost\)\.length/, + 'Map filtering should not rescan all posts just to compute openPostCount.' +); +assert.match( + mapJs, + /nearbyPreviewPosts:\s*buildNearbyPreviewPosts\(baseVisiblePosts,\s*selectedPostId\)/, + 'NearbyPreview should be built from raw filtered posts so map cards are not decorated twice.' +); +assert.doesNotMatch( + mapJs, + /nearbyPreviewPosts:\s*buildNearbyPreviewPosts\(visiblePosts,\s*selectedPostId\)/, + 'NearbyPreview should not re-decorate visiblePosts that were already prepared for the list.' +); +assert.match( + mapJs, + /this\.skipNextOnShowRefresh = true;[\s\S]*?this\.refresh\(\);[\s\S]*?onShow\(\)[\s\S]*?if \(this\.skipNextOnShowRefresh\) \{[\s\S]*?this\.skipNextOnShowRefresh = false;[\s\S]*?return;[\s\S]*?\}[\s\S]*?this\.refresh\(\);/, + 'The first onShow should skip the refresh already started by onLoad.' +); +assert.match( + mapJs, + /onHide\(\) \{[\s\S]*?this\.deactivateMapPage\(\);[\s\S]*?\}[\s\S]*?onUnload\(\) \{[\s\S]*?this\.deactivateMapPage\(\);[\s\S]*?\}[\s\S]*?deactivateMapPage\(\) \{[\s\S]*?this\.mapPageActive = false;[\s\S]*?this\.initialLocationPending = false;[\s\S]*?this\.postsRequestId = \(this\.postsRequestId \|\| 0\) \+ 1;[\s\S]*?this\.locationRequestId = \(this\.locationRequestId \|\| 0\) \+ 1;[\s\S]*?this\.discoveryRequestId = \(this\.discoveryRequestId \|\| 0\) \+ 1;[\s\S]*?this\.clearDiagnosticHideTimer\(\);[\s\S]*?\}/, + 'Map page should invalidate in-flight refreshes when hidden or unloaded.' +); +assert.match( + mapJs, + /hideDiagnosticsSoon\(\) \{[\s\S]*?if \(!this\.mapPageActive\) \{[\s\S]*?return;[\s\S]*?\}[\s\S]*?setTimeout\(\(\) => \{[\s\S]*?if \(!this\.mapPageActive\) \{[\s\S]*?this\.diagnosticHideTimer = null;[\s\S]*?return;[\s\S]*?\}/, + 'Map diagnostics should not schedule or run delayed setData while the page is inactive.' +); +assert.match( + mapJs, + /async refresh\(\) \{[\s\S]*?if \(!this\.mapPageActive\) \{[\s\S]*?return;[\s\S]*?\}[\s\S]*?const requestId = this\.nextPostsRequestId\(\);/, + 'Map refresh should not start while the page is inactive.' +); +assert.match( + mapJs, + /const posts = await this\.buildPosts\(this\.data\.center\);[\s\S]*?if \(requestId !== this\.postsRequestId\) \{[\s\S]*?return;[\s\S]*?\}/, + 'Map refresh should drop stale post results after awaiting buildPosts.' +); +assert.match( + mapJs, + /this\.locationRequestId = \(this\.locationRequestId \|\| 0\) \+ 1;[\s\S]*?const locationRequestId = this\.locationRequestId;[\s\S]*?this\.setData\(\{ center, showLocation: true, mapRegion: null \}, \(\) => \{[\s\S]*?if \(!this\.mapPageActive \|\| locationRequestId !== this\.locationRequestId\) \{[\s\S]*?return;[\s\S]*?\}[\s\S]*?this\.initialLocationPending = false;[\s\S]*?this\.refresh\(\);/, + 'Location setData callback should re-check page activity before refreshing.' +); +assert.match( + mapJs, + /showList: launchFocus\.showList,[\s\S]*?mapRegion: null[\s\S]*?\}, \(\) => \{[\s\S]*?if \(!this\.mapPageActive \|\| requestId !== this\.postsRequestId\) \{[\s\S]*?return;[\s\S]*?\}[\s\S]*?this\.applyPostFilters\(posts, 'all', null\);[\s\S]*?this\.hideDiagnostics\(\);/, + 'Focused launch setData callback should re-check page activity and request generation before applying filters.' +); +assert.match( + mapJs, + /applyPostFilters\(posts, activeCategory, mapRegion, options = \{\}\) \{[\s\S]*?if \(!this\.mapPageActive\) \{[\s\S]*?return;[\s\S]*?\}/, + 'Map filter application should not setData while inactive.' +); +assert.match( + mapJs, + /setBootStatus\(status\) \{[\s\S]*?if \(!this\.mapPageActive\) \{[\s\S]*?return;[\s\S]*?\}/, + 'Map boot status should not setData while inactive.' +); +assert.match( + mapJs, + /hideDiagnostics\(\) \{[\s\S]*?this\.clearDiagnosticHideTimer\(\);[\s\S]*?if \(!this\.mapPageActive\) \{[\s\S]*?return;[\s\S]*?\}[\s\S]*?this\.setData\(\{ diagnosticVisible: false \}\);/, + 'hideDiagnostics should not setData while inactive.' +); +assert.match( + mapJs, + /success: \(location\) => \{[\s\S]*?if \(!this\.mapPageActive \|\| locationRequestId !== this\.locationRequestId\) \{[\s\S]*?this\.initialLocationPending = false;[\s\S]*?return;[\s\S]*?\}[\s\S]*?this\.setData\(\{ center, showLocation: true, mapRegion: null \}/, + 'Location success should not update map data after the page becomes inactive.' +); +assert.match( + mapJs, + /fail: \(error\) => \{[\s\S]*?if \(!this\.mapPageActive \|\| locationRequestId !== this\.locationRequestId\) \{[\s\S]*?this\.initialLocationPending = false;[\s\S]*?return;[\s\S]*?\}[\s\S]*?addDiagnostic\('map\.getLocation\.fail'/, + 'Location failure should not update map diagnostics after the page becomes inactive.' +); +assert.match( + mapJs, + /async discoverNearby\(\) \{[\s\S]*?this\.discoveryRequestId = \(this\.discoveryRequestId \|\| 0\) \+ 1;[\s\S]*?const discoveryRequestId = this\.discoveryRequestId;[\s\S]*?await this\.discoveryCandidates\(this\.data\.activeCategory\);[\s\S]*?if \(!this\.mapPageActive \|\| discoveryRequestId !== this\.discoveryRequestId\) \{[\s\S]*?return;[\s\S]*?\}/, + 'Discovery should not update map state after the page becomes inactive or a newer discovery starts.' +); +assert.match( + mapJs, + /clearDiagnosticHideTimer\(\) \{[\s\S]*?clearTimeout\(this\.diagnosticHideTimer\);[\s\S]*?this\.diagnosticHideTimer = null;[\s\S]*?\}/, + 'Map diagnostic timer cleanup should cancel and reset the stored timer id.' +); console.log('Map feed checks passed.'); diff --git a/harness/claude-progress.md b/harness/claude-progress.md index a36037d..5b95ffb 100644 --- a/harness/claude-progress.md +++ b/harness/claude-progress.md @@ -1190,3 +1190,35 @@ - 更新过的文件或工件:`pages/map/map.js`,`pages/map/map.wxml`,`harness/check-map-feed.mjs`,`harness/feature_list.json`,`harness/claude-progress.md` - 已知风险或未解决问题:本轮只收敛“成功回首页后诊断浮层误遮挡”的问题,没有处理 DevTools 基础库偶发 `WAServiceMainContext timeout` 控制台日志;该日志此前已记录为 DevTools/native 层既有现象 - 下一步最佳动作:继续真机验证 `回首页查这条` 后列表打开、目标任务定位和无诊断遮挡;若真机仍有 native timeout 日志,再单独按 DevTools/基础库兼容问题排查 + +### Session 086FiveRoundStabilityReview + +- 日期:2026-06-22 +- 工作区:`/Users/bytedance/.codex/worktrees/0422/x`;AGENTS.md 里的历史根路径是 `/Users/bytedance/git/x`,本轮按当前 Codex worktree 执行并记录 +- 本轮目标:按用户要求建立小程序开发、JS、架构三个子 agent 方向,连续至少 5 轮检查并优化性能/稳定性;每轮实现后启动同身份 reviewer,至少 2 票 PASS 才进入下一轮 +- 已完成第 1 轮:admin 身份刷新稳定性。`utils/auth.js` 的 `refreshAdminRole` 现在在无 cloud runtime、getMyRole 失败、admins 集合缺失、非 admin 或 malformed 响应时保留 guest 状态并清除 stale admin;只有 `ok === true && role === 'admin'` 才升级管理员。`scripts/check-admin-auth-errors.mjs` 扩展了缺 cloud、调用失败、集合缺失、非管理员、legacy/malformed admin、stale admin 降级和严格 admin 成功用例。复审结果:Pauli PASS、Mill PASS、Copernicus PASS +- 已完成第 2 轮:详情页信任动作与关闭动作防重复/失败恢复。`pages/detail/detail.js`/`.wxml` 新增 `busyAction` 和 `resolving`,确认/过时/举报/关闭有 loading/disabled、try/catch/finally 和失败 toast;`utils/store.js` 在本地 fallback 中围绕异步 post lookup 做重复 reaction recheck;新增 `scripts/check-detail-action-guards.mjs` 并接入 readiness。复审结果:Einstein PASS、Schrodinger PASS、Gauss PASS +- 已完成第 3 轮:本地关闭权限边界。`utils/store.js` 的 local `resolvePost` 只允许当前发布者或管理员关闭,抛出 `FORBIDDEN`;详情页 `canResolve` 不再信任陈旧展示态 `isMine`;新增 `scripts/check-store-permission-guards.mjs` 覆盖非 owner 拒绝、owner 成功和 admin 成功并接入 readiness。复审结果:Jason PASS、Heisenberg PASS;Lagrange 已关闭,满足两票通过 +- 已完成第 4 轮:地图页生命周期/异步竞态。`pages/map/map.js` 新增 map active 标记和 posts/location/discovery request generation,onLoad 后第一次 onShow 跳过重复 refresh,hide/unload 统一 deactivate 并清 timer;refresh、location、focused launch callback、diagnostics timer 和 discoverNearby 都会丢弃 inactive/stale 回调;`harness/check-map-feed.mjs` 增加对应静态 guard。复审结果:Parfit PASS、Lorentz PASS、Plato PASS +- 已完成第 5 轮:云端评论 newest-first 稳定性。`cloudfunctions/posts/index.js` 的 `listComments` 现在在 `.limit(MAX_COMMENTS_PER_POST)` 前调用 `.orderBy('createdAt', 'desc')`,避免云端集合超过 50 条时先截断再 JS 排序;新增 `scripts/check-cloud-comment-order.mjs` 断言 `where -> orderBy -> limit -> get` 和 defensive sort,并接入 readiness。复审结果:Sagan PASS、Rawls PASS、Dewey PASS +- 运行过的验证:`pwd`;读取 `harness/claude-progress.md` 与 `harness/feature_list.json`;`git log --oneline -5`;带 bundled Node PATH 的 `bash harness/init.sh`;各轮专项 `node --check` 和 `node --no-warnings` 检查;`node --no-warnings scripts/check-devtools-readiness.mjs`;子 agent 分方向审阅;最终再次运行 `node --check` 覆盖 `pages/detail/detail.js`、`pages/map/map.js`、`utils/auth.js`、`utils/store.js`、`cloudfunctions/posts/index.js`、4 个新增/扩展检查脚本、readiness 和 `harness/check-map-feed.mjs`;最终运行 `node --no-warnings` 覆盖 admin auth、detail action、store permission、cloud comment order 和 map feed guards;最终运行 `node scripts/check-json.mjs`、`node harness/check-harness.mjs`、`node --no-warnings scripts/check-devtools-readiness.mjs`、`git diff --check`、`bash harness/init.sh` +- 已记录证据:`harness/feature_list.json` 已补充 admin/detail/map 相关 round evidence,且保留原 feature 状态;专项输出包括 `Admin auth error checks passed.`、`Detail action guard checks passed.`、`Store permission guard checks passed.`、`Cloud comment order checks passed.` 和 `Map feed checks passed.`;JSON 输出 `Checked 11 JSON files.`;harness 输出 `Harness OK: 6 features checked.`;readiness 输出最终 `DevTools readiness checks passed. Static gates passed; DevTools and real-device visual acceptance are still required.`;`git diff --check` 无输出;`bash harness/init.sh` 输出 `Harness init complete.` +- 验证限制:尝试运行 `npm run check` 时当前 shell 返回 `zsh:1: command not found: npm`;本轮已按仓库 fallback 用 bundled Node 逐项运行等价 node 门禁,但没有得到 npm wrapper 本身的通过证据 +- 已知风险或未解决问题:没有执行真实 WeChat DevTools/真机 UI 验收;readiness 仍报告 9420 service port/smoke blocker 属于环境阻塞,不是 UI passed。第 5 轮 reviewer 提醒线上高评论量场景建议在部署/运维侧确认 `post_comments(postId, status, createdAt desc)` 这类组合索引 +- 下一步最佳动作:先完成本轮最终验证命令;随后在 WeChat DevTools/真机上人工复查 admin 角色、详情信任动作、关闭权限、地图 hide/show/定位/找附近,以及云端评论列表 newest-first 行为 + +### Session 087FiveRoundPerformanceReview + +- 日期:2026-06-22 +- 工作区:`/Users/bytedance/.codex/worktrees/0422/x`;AGENTS.md 里的历史根路径是 `/Users/bytedance/git/x`,本轮按当前 Codex worktree 执行并记录 +- 本轮目标:按用户要求以性能专家身份连续进行 5 轮优化;每轮提出性能优化并启动三个同身份性能子 agent 审阅,至少 2 票 PASS 才算该轮通过 +- 已完成第 1 轮:地图 `buildPosts` 保持 raw localOnly 派生帖子,不再预先对所有帖子 `decorateMapPost`;NearbyPreview 改从 raw `baseVisiblePosts` 构建,减少重复装饰。初审 Hypatia 指出 buildPosts 仍预装饰,修复后相关后续复审通过;`discoverNearby` 同步修复为只装饰 selectedPost,避免 raw selectedPost 写入 UI +- 已完成第 2 轮:地图 `applyPostFilters` 将屏幕内筛选、分类计数和 openPostCount 合并到一次 `posts.forEach`,减少重复全量扫描。初审 Nash/Poincare 捕获 discovery raw selectedPost 回归,修复后 Galileo、Kant、Faraday 复审 PASS +- 已完成第 3 轮:`utils/store.js` 为 localOnly `listPosts` 增加 source-aware 短 TTL posts cache,减少地图刷新时反复读取 `wx` storage;同时用 `postsCache.source`、内部 source 派生、cloud cache 写入限制和动态 guard 防止 local/cloud/cache source 串线。多轮复审捕获并修复 localOnly/cloud 混用、cloud mutator 污染和 caller-provided source poisoning,最终 Hilbert、Peirce、Godel PASS +- 已完成第 4 轮:`sortedDerivedPosts` 在 raw posts 阶段预过滤 hidden,避免普通列表对 hidden 帖执行 `derivePost`、距离计算和排序;`listPosts` 强制 `includeHidden: false`,`listAllPosts` 继续保留 hidden。Banach 初审指出 `listPosts(center, { includeHidden: true })` 泄漏 hidden,修复并补 guard 后 Pascal、Gibbs、Popper 复审 PASS +- 已完成第 5 轮:`pages/admin/admin-review.js` 新增 `buildAdminSummary`,用一次遍历汇总 admin filter counts 和 stats,移除计数/统计的多次 `decoratedPosts.filter` 扫描;`scripts/check-admin-review.mjs` 补强静态 guard 并接入 readiness。Raman 初审指出 guard 未证明 summary 被使用且可漏过 hoisted repeated scans,修复后 Aristotle、Curie、Bohr 复审 PASS +- 运行过的验证:带 bundled Node PATH 的 `bash harness/init.sh`;`node --check` 覆盖 `pages/map/map.js`、`utils/store.js`、`pages/admin/admin-review.js`、`harness/check-map-feed.mjs`、`scripts/check-performance-guards.mjs`、`scripts/check-admin-review.mjs`、`scripts/check-devtools-readiness.mjs`;`node --no-warnings harness/check-map-feed.mjs`;`node --no-warnings scripts/check-performance-guards.mjs`;`node --no-warnings scripts/check-admin-review.mjs`;`node --no-warnings scripts/check-devtools-readiness.mjs`;`node scripts/check-json.mjs`;`node harness/check-harness.mjs`;`git diff --check` +- 已记录证据:`harness/feature_list.json` 已补充 map/admin 性能轮次证据;专项输出包括 `Map feed checks passed.`、`Performance guard checks passed.`、`Admin review helper checks passed.`;JSON 输出 `Checked 11 JSON files.`;harness 输出 `Harness OK: 6 features checked.`;readiness 输出 `DevTools readiness checks passed. Static gates passed; DevTools and real-device visual acceptance are still required.`;`git diff --check` 无输出 +- 验证限制:当前 shell 中 `command -v npm` 无输出,未运行 npm wrapper;本轮使用 bundled Node 路径逐项运行仓库门禁。没有执行真实 WeChat DevTools/真机 UI 验收,readiness 中的 9420 service-port/smoke blocker 仍是环境阻塞,不是 UI passed evidence +- 已知风险或未解决问题:这些是性能和静态/动态 guard 优化,不改变 feature_list 的人工验收状态;地图原生层、真实定位、管理台真实管理员账号和真机性能体感仍需 DevTools/真机观察 +- 下一步最佳动作:如果要把本轮性能改动合入现有 PR,需要先提交并推送当前工作区改动;之后在 WeChat DevTools/真机复查地图列表/附近预览、localOnly 首屏刷新、hidden 任务可见性边界和管理台筛选统计 diff --git a/harness/feature_list.json b/harness/feature_list.json index 39402b8..8f87dba 100644 --- a/harness/feature_list.json +++ b/harness/feature_list.json @@ -1,6 +1,6 @@ { "project": "Street Tasks / 街区任务", - "last_updated": "2026-06-17", + "last_updated": "2026-06-22", "rules": { "single_active_feature": true, "passing_requires_evidence": true, @@ -163,7 +163,13 @@ "2026-06-17: AF second review follow-up disallowed blocked conditional artifact slots when the source journey is passed, while permitting tightly justified not_applicable, and added a fieldsObserved sensitive-name denylist so coordinate, environment, identity, credential, raw payload, and raw CloudBase field names cannot be cited as safe field-only observations.", "2026-06-17: AF third review follow-up derives conditional slot triggers from source results and environment: CloudBase enabled/deployed makes cloud-readback triggered, inspectable payload fields make payload-sample triggered, and triggered conditional slots on passed journeys must be present rather than not_applicable; snake_case identity/env aliases are now blocked in both object keys and fieldsObserved.", "2026-06-17: AF final review follow-up added raw/cloud/file/event canonical aliases such as rawpayload, rawquery, clouduri, cloudfileid, fileid, and eventid to the shared sensitive field-name set, closing raw_payload object-key and cloud_file_id fieldsObserved bypasses; final review returned No Critical/Important findings and Ready yes.", - "2026-06-17: AF verification covered node --check for the new artifact manifest scripts and readiness, a blocked ignored local viral results/manifest positive guard, preflight and npm run check:viral-manual-artifact-manifest positive runs, and negative cases for duplicate-key raw path leakage, missing risk-state-note, sensitive latitude key, source blocker mismatch, timeline payload missing title/imageUrl, weak CloudBase readback fields, raw camelCase payload leakage, sensitive fieldsObserved latitude/env_id, snake_case identity keys, passed source journey with blocked conditional payload slot, triggered CloudBase readback not_applicable, raw_payload object key, and cloud_file_id fieldsObserved; final npm run check and bash harness/init.sh returned 0, but the local fixture remains blocked and does not provide DevTools UI, real-device, or viral journey passed evidence." + "2026-06-17: AF verification covered node --check for the new artifact manifest scripts and readiness, a blocked ignored local viral results/manifest positive guard, preflight and npm run check:viral-manual-artifact-manifest positive runs, and negative cases for duplicate-key raw path leakage, missing risk-state-note, sensitive latitude key, source blocker mismatch, timeline payload missing title/imageUrl, weak CloudBase readback fields, raw camelCase payload leakage, sensitive fieldsObserved latitude/env_id, snake_case identity keys, passed source journey with blocked conditional payload slot, triggered CloudBase readback not_applicable, raw_payload object key, and cloud_file_id fieldsObserved; final npm run check and bash harness/init.sh returned 0, but the local fixture remains blocked and does not provide DevTools UI, real-device, or viral journey passed evidence.", + "2026-06-22: Multi-agent optimization round 4 hardened map lifecycle races: first onShow skips duplicate onLoad refresh, hide/unload deactivate invalidates posts/location/discovery request generations, in-flight refresh/location/discovery callbacks drop stale results, and diagnostic timers are cleared before inactive updates.", + "2026-06-22: harness/check-map-feed.mjs now guards map inactive/stale callback paths, diagnostic timer cleanup, focused launch callback guards, and discovery request invalidation; round 4 reviewers Parfit, Lorentz, and Plato all voted PASS.", + "2026-06-22: Performance round 1 kept pages/map buildPosts on raw localOnly posts and built NearbyPreview from baseVisiblePosts to avoid duplicate decorateMapPost work; after a reviewer caught pre-decoration and raw selectedPost edge cases, corrected guards passed re-review.", + "2026-06-22: Performance round 2 collapsed map viewport filtering, category counts, and open-post count into one posts.forEach pass; after fixing the discovery selectedPost decoration regression, reviewers Galileo, Kant, and Faraday voted PASS.", + "2026-06-22: Performance round 3 added source-aware short-TTL posts caching for localOnly listPosts to avoid repeated wx storage reads while preventing local/cloud cache pollution; after multiple reviewer-caught source and cloud-mutator fixes, reviewers Hilbert, Peirce, and Godel voted PASS.", + "2026-06-22: Performance round 4 prefiltered hidden raw posts before derivePost/sort while forcing normal listPosts callers to includeHidden=false and preserving listAllPosts hidden visibility; after Banach caught includeHidden leakage, reviewers Pascal, Gibbs, and Popper voted PASS." ], "notes": "已有实现未等于 harness passing;需要手动小程序验证证据。2026-05-18 UI 刷新采用 TDesign 式设计 token 和原生小程序控件落地,未引入 tdesign-miniprogram npm 依赖,避免在当前无小程序 npm 构建链路下增加编译风险。底部 tabBar 已由悬浮胶囊改为贴底不透明导航栏。2026-05-19 发布页和地图抽屉已落地 design-ui-designer 组件化优化。2026-05-21 白屏排查未发现 WXML/WXSS/JS 语法错误,已移除 tabBar 中新增的空 cover-view 指示条以降低渲染兼容风险;同日继续排查确认 DevTools hot reload/getAppConfig 缓存会触发 subPackages 内部错误,清编译缓存并终止重编译后地图恢复,同时加入页面诊断和云读取 fallback;随后针对 `Error: timeout` 将地图首屏改为本地优先,定位、云函数列表和地图 region 读取都不再阻塞启动;详情页已补上本地 fallback,避免本地列表任务因云端不存在而显示“任务不存在”。信息列表已改为铺满抽屉、标题后置标签、底部轻量统计。A 组地图迭代新增 NearbyPreview 首屏附近优先预览和选中态联动,并收敛长标题风险;2026-06-15 用户手测暴露普通 view/button 覆盖原生 map 不可靠,因此折叠态任务入口、定位和找一找都保持为 cover-view/cover-image;经过多轮用户截图反馈,最终布局按偏好收敛为右上角白色“列表 N”单行居中按钮、右下角定位/找一找工具行、选中任务卡上移避让工具行,打开列表时仍缩小 native map 到 38vh 让普通任务卡抽屉位于 map 下方。2026-06-14 已新增地图列表静态韧性检查并接入 readiness/preflight,且手测准备 helper 会显式运行并提示该 preflight;这只降低结构、样式和执行漏跑风险,不代表 DevTools 或真机视觉验收通过。S 组补充了地图列表真实视觉 smoke 的模板 journey 和检查清单,T 组把该 journey 变成 manual evidence 必备项,U 组提供 blocked local draft helper,V 组把 blocked draft 与 sanitized summary 生成串联,W 组为 blocked JSON/summary 状态一致性增加自动 guard,X 组进一步守住 summary 与 JSON 的 branch/commit/blocker/followUp 同源完整性,Y 组把增强 guard 接入 wrapper 默认生成链路,Z 组又把生成后手工编辑必须复跑 guard 的命令直接打印到 wrapper 成功输出,AA 组新增评审前一键 preflight 扫描 ignored local blocked summaries 并逐对复跑 guard,AB 组把该 preflight 接入 harness/init.sh 和 DevTools readiness 默认检查,AC 组把 JSON/harness/readiness 门禁暴露为 npm 级 `check` 脚本,AD 组新增最小 GitHub Actions workflow 在 push/pull_request 上运行该检查,AE 组补充 required-check runbook 和稳定 job display name 以便远端 Actions/分支保护后续验证,AF 组补充 `inspect:devtools-port` 与 `check:devtools-smoke` 手动入口,明确当前 9420 service-port blocker 可复查但不进入默认 CI/check,AG 组补充 `inspect:devtools-recovery` dry-run 入口,明确恢复动作前后的 before/actions/after/next-step 证据且不执行 quit/open 副作用,AH 组补充 ignored local recovery dry-run report 生成与 guard,降低交接草稿被改写成恢复成功或 UI passed 的风险,AI 组再补充手动 preflight 扫描所有 ignored local recovery reports 并逐份复跑 guard,降低多份本地草稿交接前漏检风险;这些仍只是在证据结构上预留、守护、演练阻塞记录并提升评审可读性。viral 侧 AD/AE/AF 又把七条旅程证据包、JSON/Markdown summary 同源和附件 manifest 同源/脱敏 guard 串起来,能让真实手测后的截图/录屏/payload/readback 附件更安全地被评审引用;但这些 guard 仍不产生真实系统分享、朋友圈、payload、CloudBase readback、窄屏或真机 passed evidence。真实 safe area、地图抽屉、原生地图层、列表滚动、带图/无图和详情点击仍需 WeChat DevTools 或真机观察。仍需在 WeChat DevTools/真机中确认 safe area、键盘、地图抽屉、定位授权分支和多分类详情链路。" }, @@ -328,7 +334,10 @@ "2026-06-17: Detail share paths created after a low-risk from=share receiver conversion now append share_id, parent_share_id, and share_depth while preserving from=share, source=receiver, and receiverAction=confirm/comment.", "2026-06-17: Review follow-up made from=share timeline re-shares append the same relay attribution params to onShareTimeline query and record a timeline relay event; receiverConversion relay events now include conversion_action=confirm/comment derived from receiverAction.", "2026-06-17: cloudfunctions/posts/index.js now exposes recordViralAttribution and writes sanitized events to viral_attribution_events with user_id_hash instead of raw openid; README, PROJECT_SUMMARY, and AGENTS.md document the new collection and privacy boundary.", - "2026-06-17: scripts/check-viral-attribution.mjs is wired into scripts/check-viral-candidate.mjs and scripts/check-devtools-readiness.mjs; viral journey manual evidence now requires relay attribution params in inspectable receiver confirm/comment share payloads without claiming DevTools or real-device UI passed." + "2026-06-17: scripts/check-viral-attribution.mjs is wired into scripts/check-viral-candidate.mjs and scripts/check-devtools-readiness.mjs; viral journey manual evidence now requires relay attribution params in inspectable receiver confirm/comment share payloads without claiming DevTools or real-device UI passed.", + "2026-06-22: Multi-agent optimization round 2 added detail trust/resolve busy guards, button loading/disabled states, toastable error handling, and local duplicate trust-action rechecks around the async post lookup; scripts/check-detail-action-guards.mjs is wired into readiness and round 2 reviewers voted PASS.", + "2026-06-22: Multi-agent optimization round 3 moved local resolve permission enforcement into utils/store.js, allowing only owner/admin resolution and ignoring stale presentation isMine fields; scripts/check-store-permission-guards.mjs covers non-owner rejection, owner success, and admin success, and round 3 received at least two PASS votes.", + "2026-06-22: Multi-agent optimization round 5 moved CloudBase listComments ordering before MAX_COMMENTS_PER_POST limit with orderBy('createdAt', 'desc'), added scripts/check-cloud-comment-order.mjs, wired it into readiness, and received PASS votes from mini program, JS, and architecture reviewers." ], "notes": "评论与信任动作都涉及本地 storage 和 CloudBase 两条路径;当前代码实现已推进,仍需要 WeChat DevTools 手动验证后才能标记 passing。2026-05-21 详情头部已补充发布者头像/名称与右侧地址时间同排展示。当前会话转向发布图片云存储修复,避免同时存在多个 in_progress 功能。 2026-06-12 C 组详情迭代新增 TrustInsight 信任判断面板和评论头部入口;尚未在 WeChat DevTools/真机验证窄屏布局、信任动作刷新、游客登录评论引导和云端评论路径。 第二轮 C 组已收敛 TrustInsight 谨慎文案和冲突优先级自动检查;2026-06-16 组合候选同时保留发布成功扩散计划和普通详情分享提示,并新增接收侧转化提示,但仍未执行 DevTools/真机视觉、分享面板、接收路径、窄屏密度、信任动作刷新和云端评论验证。 2026-06-16 评论成功后接力提示已加入组合候选:只在评论提交成功后显示,active 低风险任务可接力转发,stale/高举报/resolved/expired/hidden 不鼓励公开扩散。传播闭环候选进一步让普通分享面板在发布成功、分享接收和评论接力状态下隐藏,降低同屏传播 CTA 竞争;确认成功后接力提示进一步覆盖 confirm 高意图时刻,且 stale/report/高风险状态不鼓励公开扩散。接收者转化迭代进一步覆盖 from=share 用户完成 confirm/comment 后的二跳接力,并用 source=receiver 让下一位接收者理解链路来源;普通详情入口、stale/report/高风险和关闭态不鼓励公开扩散。本轮接收者第一步行动入口进一步让 from=share active 且无过时/举报信号的低风险用户能直接确认或补线索,并在完成 confirm/comment 后继续优先出现 receiverConversionPrompt;普通入口、有过时/举报信号、hidden/resolved/expired 不显示鼓励性 action strip。N 组新增传播链路证据框架,把首跳 from=share、接收者行动、转化提示、二跳 source=receiver 和风险态互斥固化为自动场景模型,并提供 not_run 手测模板;这仍不代表 WeChat DevTools/真机通过。仍未执行 WeChat DevTools/真机验证五种入口视觉层级、action strip 点击、open-type share、风险态按钮、窄屏换行和真实云端评论路径。 O 组进一步把 N 组 not_run 模板升级为真实本地手测结果 gate:默认只扫描 ignored/local viral journey 结果文件,无文件时通过但明确不代表 UI passed;真实文件存在时必须匹配当前分支/commit、完整环境和唯一 required journeys,并按 passed/failed/blocked 规则校验 evidence、actual、blocker、followUp、share payload 或无法检查说明以及 overallStatus 聚合。prepare helper 只生成 ignored blocked draft 或 dry-run 输出,不声称 passed。P 组新增 no-side-effect 手测启动包,把 DevTools port/smoke blocker、evidence dry-run/check 和五条 journey 下一步收敛到单一命令;当前 9420 仍是环境 blocker,不是 UI failed 或 UI passed。Q 组新增显式 blocked evidence capture 命令,把当前 port/smoke blocker 写入 ignored local JSON 并自动跑 O checker;capture 文件仍是 blocked evidence,不是 UI passed,且默认 readiness 不执行写文件 capture。R 组把接收者完成确认/评论后的二跳提示从泛化“继续接力”升级为目标化三行信息,明确推荐转给谁、为什么可信和下一位先看什么;分类文案已对齐当前 lost_found/help_needed/street_update/check_in 枚举,风险和关闭态仍不显示鼓励性 target rows。S 组进一步在低风险二跳 path 上加入 receiverAction=confirm/comment,并让下一位接收者区分上一位刚确认或刚补线索;缺失/未知 action、非 receiver source 和风险/关闭态继续安全回退。仍未执行 WeChat DevTools/真机真实链路验证。T 组在低风险 from=share 接收者完成 confirm/comment 后新增一条可转述短理由,放在目标化三行信息与继续接力按钮之间,confirm/comment 文案不同且不新增分享 query;风险、关闭和弱 stale/report 仍不显示鼓励性理由。仍未执行 WeChat DevTools/真机真实链路验证。U 组进一步在低风险 from=share 接收者完成 confirm/comment 后增加 2-3 个“适合转给”的泛化场景建议,补足 T 组 shareReason 只解决“怎么说”的短板;它不读取联系人/微信群、不新增 targeting query,风险、关闭和弱 stale/report 仍不显示鼓励性场景建议。V 组新增微信原生分享到朋友圈渠道:只在低风险 active 详情页菜单中暴露 shareTimeline,timeline query 复用 from=share 并用 source=timeline/shareChannel=timeline 区分渠道;风险、关闭、弱 stale/report 和未知任务不开放朋友圈菜单且 payload 文案谨慎。W 组把真实朋友圈渠道纳入 manual evidence schema,新增 timeline-share-channel 与 timeline-risk-gating 两条 required journey,run package 和 blocked capture 都改为七条;这仍只是证据结构与阻塞记录能力,尚未产生真实 DevTools/真机 timeline passed evidence。X 组进一步让 source=timeline 落地页不再只是普通转发语境:低风险 active 朋友圈访客会看到“朋友圈看到”的附近任务核对说明,先看状态/评论,再确认或补线索;弱 stale/report、风险和关闭态仍优先谨慎。" }, @@ -359,7 +368,9 @@ "2026-06-13 admin iteration branch: WeChat DevTools local wcsc -lc compiled all WXSS files with exit code 0.", "2026-06-13 admin iteration branch: bash harness/init.sh passed; npm install reported up to date, then JSON and harness checks passed.", "2026-06-15: After user-reported getMyRole failure, added formatAdminRoleError so wx-server-sdk missing dependency and undeployed cloud function failures show short actionable admin-check messages instead of raw cloud.callFunction stack traces.", - "2026-06-15: Added scripts/check-admin-auth-errors.mjs and wired it into scripts/check-devtools-readiness.mjs so npm run check guards admin auth error formatting." + "2026-06-15: Added scripts/check-admin-auth-errors.mjs and wired it into scripts/check-devtools-readiness.mjs so npm run check guards admin auth error formatting.", + "2026-06-22: Multi-agent optimization round 1 hardened refreshAdminRole so missing cloud runtime, getMyRole errors, missing admins collection, malformed responses, and non-admin responses preserve guest state while clearing stale admin role; only ok === true and role === 'admin' upgrades to admin. scripts/check-admin-auth-errors.mjs now covers these cases and round 1 reviewers voted PASS.", + "2026-06-22: Performance round 5 replaced repeated admin review filter/stat full-list scans with one-pass buildAdminSummary, kept filter/stat semantics, strengthened scripts/check-admin-review.mjs after Raman caught weak guard coverage, wired the guard into readiness, and re-reviewers Aristotle, Curie, and Bohr voted PASS." ], "notes": "管理员身份依赖 getMyRole 云函数和本地 fallback。本实验分支已补充风险解释、建议动作、处置中防重复点击和确认弹窗信息;2026-06-15 已把云函数缺少 wx-server-sdk 或未部署的错误收敛为短提示和处理步骤。但真实管理员通过仍需要在 CloudBase 中重新上传部署 getMyRole 云函数并创建 admins 记录,且尚未在 WeChat DevTools 中以普通用户/管理员身份手动验证,因此不标记 passing。" }, diff --git a/pages/admin/admin-review.js b/pages/admin/admin-review.js index bc69ec9..8b8747f 100644 --- a/pages/admin/admin-review.js +++ b/pages/admin/admin-review.js @@ -198,8 +198,57 @@ function matchesQuery(post, query) { return terms.every((term) => post.searchText.indexOf(term) >= 0); } -function countForFilter(posts, filter) { - return posts.filter((post) => filterPost(post, filter)).length; +function buildAdminSummary(posts) { + const filterCounts = adminFilterOptions.reduce((counts, item) => { + counts[item.value] = 0; + return counts; + }, {}); + const stats = { + total: 0, + needsReview: 0, + active: 0, + stale: 0, + resolved: 0, + reported: 0, + hidden: 0 + }; + + posts.forEach((post) => { + const postNeedsReview = needsReview(post); + stats.total += 1; + filterCounts.all += 1; + + if (postNeedsReview) { + stats.needsReview += 1; + filterCounts.needs_review += 1; + } + if (post.reportCount > 0) { + stats.reported += 1; + filterCounts.reported += 1; + } + if (post.staleCount > 0 || post.status === 'stale') { + filterCounts.stale += 1; + } + if (post.status === 'active') { + stats.active += 1; + filterCounts.active += 1; + } + if (isClosed(post)) { + filterCounts.closed += 1; + } + if (post.status === 'stale') { + stats.stale += 1; + } + if (post.status === 'resolved') { + stats.resolved += 1; + } + if (post.status === 'hidden') { + stats.hidden += 1; + filterCounts.hidden += 1; + } + }); + + return { filterCounts, stats }; } export function buildAdminReviewState(posts, options = {}) { @@ -211,22 +260,15 @@ export function buildAdminReviewState(posts, options = {}) { const visiblePosts = decoratedPosts .filter((post) => filterPost(post, activeFilter)) .filter((post) => matchesQuery(post, query)); + const summary = buildAdminSummary(decoratedPosts); return { posts: decoratedPosts, visiblePosts, filterOptions: adminFilterOptions.map((item) => ({ ...item, - count: countForFilter(decoratedPosts, item.value) + count: summary.filterCounts[item.value] || 0 })), - stats: { - total: decoratedPosts.length, - needsReview: decoratedPosts.filter(needsReview).length, - active: decoratedPosts.filter((post) => post.status === 'active').length, - stale: decoratedPosts.filter((post) => post.status === 'stale').length, - resolved: decoratedPosts.filter((post) => post.status === 'resolved').length, - reported: decoratedPosts.filter((post) => post.reportCount > 0).length, - hidden: decoratedPosts.filter((post) => post.status === 'hidden').length - } + stats: summary.stats }; } diff --git a/pages/detail/detail.js b/pages/detail/detail.js index ff5be97..ecca885 100644 --- a/pages/detail/detail.js +++ b/pages/detail/detail.js @@ -52,7 +52,7 @@ function publisherInitial(name) { function decorateDetailPost(raw) { const user = getCurrentUser(); const canReact = raw.status === 'active' || raw.status === 'stale'; - const canResolve = canReact && (isAdmin(user) || raw.isMine || raw.publisherId === user.id); + const canResolve = canReact && (isAdmin(user) || raw.publisherId === user.id); const canShowResolve = canResolve && raw.category !== 'check_in'; const publisherName = String(raw.publisher || '附近用户').trim() || '附近用户'; return { @@ -119,6 +119,8 @@ Page({ commentDraftLength: 0, commentSubmitting: false, showCommentDialog: false, + busyAction: '', + resolving: false, maxCommentLength: MAX_COMMENT_LENGTH, actionRelayPrompt: null, commentRelayPrompt: null, @@ -436,6 +438,9 @@ Page({ async react(event) { const action = event.currentTarget.dataset.action; + if (this.data.busyAction) { + return; + } if (hasReactedToPost(this.data.id, action)) { wx.showToast({ title: '这条已记录过', @@ -443,21 +448,35 @@ Page({ }); return; } - const post = await reactToPost(this.data.id, action); - wx.showToast({ - title: action === 'report' ? '已收到举报' : '已记录', - icon: 'success' - }); - this.renderPost(post); - const receiverConversionPrompt = buildReceiverConversionPrompt(post, action, { - entryFrom: this.data.entryQuery.from - }); - recordShareConversion(this.data.attributionSession, post, action); - this.setData({ - actionRelayPrompt: receiverConversionPrompt ? null : buildActionRelayPrompt(post, action), - commentRelayPrompt: null, - receiverConversionPrompt - }); + this.setData({ busyAction: action }); + try { + const post = await reactToPost(this.data.id, action); + wx.showToast({ + title: action === 'report' ? '已收到举报' : '已记录', + icon: 'success' + }); + this.renderPost(post); + if (!post) { + return; + } + const receiverConversionPrompt = buildReceiverConversionPrompt(post, action, { + entryFrom: this.data.entryQuery.from + }); + recordShareConversion(this.data.attributionSession, post, action); + this.setData({ + actionRelayPrompt: receiverConversionPrompt ? null : buildActionRelayPrompt(post, action), + commentRelayPrompt: null, + receiverConversionPrompt + }); + } catch (error) { + console.warn('[detail] failed to react to post', error); + wx.showToast({ + title: action === 'report' ? '举报失败,请稍后再试' : '操作失败,请稍后再试', + icon: 'none' + }); + } finally { + this.setData({ busyAction: '' }); + } }, resolve() { @@ -468,20 +487,38 @@ Page({ }); return; } + if (this.data.resolving) { + return; + } + this.setData({ resolving: true }); wx.showModal({ title: this.data.post.resolveText, content: '关闭后仍会保留在列表里,方便附近用户知道这件事已经处理完。', confirmText: '关闭', success: async (result) => { if (!result.confirm) { + this.setData({ resolving: false }); return; } - const post = await resolvePost(this.data.id); - wx.showToast({ - title: '已关闭', - icon: 'success' - }); - this.renderPost(post); + try { + const post = await resolvePost(this.data.id); + wx.showToast({ + title: '已关闭', + icon: 'success' + }); + this.renderPost(post); + } catch (error) { + console.warn('[detail] failed to resolve post', error); + wx.showToast({ + title: error && error.code === 'FORBIDDEN' ? '没有权限关闭' : '关闭失败,请稍后再试', + icon: 'none' + }); + } finally { + this.setData({ resolving: false }); + } + }, + fail: () => { + this.setData({ resolving: false }); } }); }, diff --git a/pages/detail/detail.wxml b/pages/detail/detail.wxml index d7f9502..b07405d 100644 --- a/pages/detail/detail.wxml +++ b/pages/detail/detail.wxml @@ -64,7 +64,7 @@ {{shareReceiverActionStrip.body}} - + @@ -192,15 +192,15 @@ {{trustInsight.hint}} - - - @@ -213,7 +213,7 @@ - + diff --git a/pages/map/map.js b/pages/map/map.js index 2a1731a..762f767 100644 --- a/pages/map/map.js +++ b/pages/map/map.js @@ -67,11 +67,13 @@ Page({ onLoad(options = {}) { addDiagnostic('map.onLoad', 'entered'); + this.mapPageActive = true; const focusPostId = safeDecode(options.focusPostId || options.postId || options.id); this.pendingLaunchFocus = focusPostId ? { id: focusPostId, showList: shouldOpenList(options.showList) } : null; this.initialLocationPending = false; + this.skipNextOnShowRefresh = true; this.setBootStatus('地图页已加载,正在使用默认附近信息'); if (wx.showShareMenu) { wx.showShareMenu({ @@ -88,6 +90,7 @@ Page({ }, onShow() { + this.mapPageActive = true; try { syncTabBar(this, '/pages/map/map'); } catch (error) { @@ -96,15 +99,42 @@ Page({ if (this.initialLocationPending) { return; } + if (this.skipNextOnShowRefresh) { + this.skipNextOnShowRefresh = false; + return; + } this.refresh(); }, + onHide() { + this.deactivateMapPage(); + }, + + onUnload() { + this.deactivateMapPage(); + }, + + deactivateMapPage() { + this.mapPageActive = false; + this.initialLocationPending = false; + this.postsRequestId = (this.postsRequestId || 0) + 1; + this.locationRequestId = (this.locationRequestId || 0) + 1; + this.discoveryRequestId = (this.discoveryRequestId || 0) + 1; + this.clearDiagnosticHideTimer(); + }, + locateCurrent() { this.initialLocationPending = true; + this.locationRequestId = (this.locationRequestId || 0) + 1; + const locationRequestId = this.locationRequestId; this.setBootStatus('正在获取当前位置'); wx.getLocation({ type: 'gcj02', success: (location) => { + if (!this.mapPageActive || locationRequestId !== this.locationRequestId) { + this.initialLocationPending = false; + return; + } addDiagnostic('map.getLocation.success', 'location ready'); const center = { latitude: Number(location.latitude.toFixed(6)), @@ -113,11 +143,18 @@ Page({ }; app.globalData.center = center; this.setData({ center, showLocation: true, mapRegion: null }, () => { + if (!this.mapPageActive || locationRequestId !== this.locationRequestId) { + return; + } this.initialLocationPending = false; this.refresh(); }); }, fail: (error) => { + if (!this.mapPageActive || locationRequestId !== this.locationRequestId) { + this.initialLocationPending = false; + return; + } addDiagnostic('map.getLocation.fail', error || 'using default center'); this.setBootStatus('定位未授权或暂不可用,继续使用默认位置'); this.initialLocationPending = false; @@ -127,6 +164,9 @@ Page({ }, async refresh() { + if (!this.mapPageActive) { + return; + } const requestId = this.nextPostsRequestId(); this.setBootStatus('正在加载附近信息'); try { @@ -149,6 +189,9 @@ Page({ showList: launchFocus.showList, mapRegion: null }, () => { + if (!this.mapPageActive || requestId !== this.postsRequestId) { + return; + } this.applyPostFilters(posts, 'all', null); this.hideDiagnostics(); }); @@ -165,8 +208,7 @@ Page({ async buildPosts(center) { // Keep the map first paint independent from cloud/network startup timing. - const posts = await listPosts(center, { localOnly: true }); - return posts.map((post) => decorateMapPost(post)); + return listPosts(center, { localOnly: true }); }, consumeLaunchFocus(posts) { @@ -190,14 +232,25 @@ Page({ }, applyPostFilters(posts, activeCategory, mapRegion, options = {}) { - const viewportPosts = posts.filter((post) => isPostInRegion(post, mapRegion)); - const baseVisiblePosts = activeCategory === 'all' - ? viewportPosts - : viewportPosts.filter((post) => post.category === activeCategory); + if (!this.mapPageActive) { + return; + } + const viewportPosts = []; const categoryCounts = {}; - viewportPosts.forEach((post) => { + let openPostCount = 0; + posts.forEach((post) => { + if (isOpenPost(post)) { + openPostCount += 1; + } + if (!isPostInRegion(post, mapRegion)) { + return; + } + viewportPosts.push(post); categoryCounts[post.category] = (categoryCounts[post.category] || 0) + 1; }); + const baseVisiblePosts = activeCategory === 'all' + ? viewportPosts + : viewportPosts.filter((post) => post.category === activeCategory); const categoryFilters = categoryOptions.map((item) => ({ ...item, count: item.value === 'all' ? viewportPosts.length : categoryCounts[item.value] || 0 @@ -218,11 +271,11 @@ Page({ : null; const nextData = { visiblePosts, - nearbyPreviewPosts: buildNearbyPreviewPosts(visiblePosts, selectedPostId), + nearbyPreviewPosts: buildNearbyPreviewPosts(baseVisiblePosts, selectedPostId), categoryFilters, activeCategory, activeCategoryText: activeFilter ? activeFilter.label : '全部', - openPostCount: posts.filter(isOpenPost).length, + openPostCount, viewportPostCount: viewportPosts.length, mapRegion, selectedPost, @@ -240,6 +293,9 @@ Page({ }, setBootStatus(status) { + if (!this.mapPageActive) { + return; + } if (!this.data.diagnosticVisible) { return; } @@ -253,7 +309,7 @@ Page({ if (error) { addDiagnostic('map.runtime', error); } - clearTimeout(this.diagnosticHideTimer); + this.clearDiagnosticHideTimer(); this.setData({ diagnosticVisible: true, diagnosticTitle: title || '运行诊断', @@ -263,17 +319,36 @@ Page({ }, hideDiagnosticsSoon() { - clearTimeout(this.diagnosticHideTimer); + if (!this.mapPageActive) { + return; + } + this.clearDiagnosticHideTimer(); this.diagnosticHideTimer = setTimeout(() => { + if (!this.mapPageActive) { + this.diagnosticHideTimer = null; + return; + } + this.diagnosticHideTimer = null; this.setData({ diagnosticVisible: false }); }, DIAGNOSTIC_HIDE_MS); }, hideDiagnostics() { - clearTimeout(this.diagnosticHideTimer); + this.clearDiagnosticHideTimer(); + if (!this.mapPageActive) { + return; + } this.setData({ diagnosticVisible: false }); }, + clearDiagnosticHideTimer() { + if (!this.diagnosticHideTimer) { + return; + } + clearTimeout(this.diagnosticHideTimer); + this.diagnosticHideTimer = null; + }, + retryFromDiagnostics() { this.setData({ diagnosticVisible: true, @@ -347,7 +422,12 @@ Page({ }, async discoverNearby() { + this.discoveryRequestId = (this.discoveryRequestId || 0) + 1; + const discoveryRequestId = this.discoveryRequestId; const { candidates, activeCategory } = await this.discoveryCandidates(this.data.activeCategory); + if (!this.mapPageActive || discoveryRequestId !== this.discoveryRequestId) { + return; + } if (!candidates.length) { wx.showToast({ title: '附近暂时没有内容', @@ -376,7 +456,7 @@ Page({ center, activeCategory, showList: false, - selectedPost: post, + selectedPost: decorateMapPost(post, post.id), mapRegion: null }, () => this.refresh()); }, diff --git a/scripts/check-admin-auth-errors.mjs b/scripts/check-admin-auth-errors.mjs index 79b6ac3..21a6c6b 100644 --- a/scripts/check-admin-auth-errors.mjs +++ b/scripts/check-admin-auth-errors.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; -import { formatAdminRoleError } from '../utils/auth.js'; +import { formatAdminRoleError, refreshAdminRole } from '../utils/auth.js'; const missingSdk = formatAdminRoleError({ errMsg: "cloud.callFunction:fail Error: Cannot find module 'wx-server-sdk'" @@ -25,4 +25,173 @@ const unknown = formatAdminRoleError({ assert.equal(unknown.reason, '管理员校验云函数暂不可用'); assert.match(unknown.nextStep, /检查云开发环境/); +let storage = {}; + +function setWx(cloud) { + storage = {}; + globalThis.wx = { + cloud, + getStorageSync(key) { + return storage[key]; + }, + setStorageSync(key, value) { + storage[key] = value; + } + }; +} + +async function assertGuestPreserved(label, cloud) { + setWx(cloud); + wx.setStorageSync('user', { + id: 'guest_for_admin_check', + nickname: '天枢', + avatarUrl: '', + role: 'user', + isGuest: true, + profileCompleted: false, + loggedInAt: 0 + }); + const result = await refreshAdminRole(); + assert.equal(result.ok, false, `${label}: admin check should fail`); + assert.equal(result.user.isGuest, true, `${label}: failed admin check must keep guest state`); + assert.equal(wx.getStorageSync('user').isGuest, true, `${label}: stored user must remain guest`); + assert.equal(wx.getStorageSync('user').loggedInAt, 0, `${label}: failed admin check must not stamp login time`); +} + +async function assertAdminDowngraded(label, cloud) { + setWx(cloud); + wx.setStorageSync('user', { + id: 'stale_admin', + nickname: '管理员', + avatarUrl: '', + role: 'admin', + authSource: 'cloud', + isGuest: false, + profileCompleted: true, + loggedInAt: 12345 + }); + const result = await refreshAdminRole(); + const storedUser = wx.getStorageSync('user'); + assert.equal(result.ok, false, `${label}: stale admin check should fail`); + assert.equal(storedUser.role, 'user', `${label}: stale admin role must be revoked`); + assert.equal(storedUser.authSource, '', `${label}: stale admin cloud auth source must be cleared`); + assert.equal(storedUser.isGuest, false, `${label}: logged-in non-admin user should stay non-guest`); + assert.equal(storedUser.loggedInAt, 12345, `${label}: fallback should keep the existing login timestamp`); +} + +await assertGuestPreserved('missing cloud role runtime', undefined); + +await assertGuestPreserved('cloud function failure', { + callFunction() { + return Promise.reject({ errMsg: 'cloud.callFunction:fail timeout' }); + } +}); + +await assertGuestPreserved('missing admins collection', { + callFunction() { + return Promise.resolve({ + result: { + role: 'user', + ok: false, + reason: '管理员配置不可用' + } + }); + } +}); + +await assertGuestPreserved('not an admin', { + callFunction() { + return Promise.resolve({ + result: { + role: 'user', + ok: false, + reason: '当前微信不是管理员' + } + }); + } +}); + +await assertGuestPreserved('legacy user response without ok', { + callFunction() { + return Promise.resolve({ + result: { + role: 'user' + } + }); + } +}); + +await assertGuestPreserved('non-admin positive-looking response', { + callFunction() { + return Promise.resolve({ + result: { + role: 'user', + ok: true, + reason: '' + } + }); + } +}); + +await assertGuestPreserved('admin-shaped response without ok', { + callFunction() { + return Promise.resolve({ + result: { + role: 'admin' + } + }); + } +}); + +await assertGuestPreserved('admin-shaped response with non-boolean ok', { + callFunction() { + return Promise.resolve({ + result: { + role: 'admin', + ok: 'true' + } + }); + } +}); + +await assertAdminDowngraded('stale admin rejected by role check', { + callFunction() { + return Promise.resolve({ + result: { + role: 'user', + ok: false, + reason: '当前微信不是管理员' + } + }); + } +}); + +setWx({ + callFunction() { + return Promise.resolve({ + result: { + role: 'admin', + ok: true, + reason: '' + } + }); + } +}); +wx.setStorageSync('user', { + id: 'guest_admin', + nickname: '天璇', + avatarUrl: '', + role: 'user', + isGuest: true, + profileCompleted: false, + loggedInAt: 0 +}); +const adminResult = await refreshAdminRole(); +const storedAdmin = wx.getStorageSync('user'); +assert.equal(adminResult.ok, true); +assert.equal(adminResult.user.role, 'admin'); +assert.equal(adminResult.user.isGuest, false); +assert.equal(storedAdmin.role, 'admin'); +assert.equal(storedAdmin.isGuest, false); + console.log('Admin auth error checks passed.'); diff --git a/scripts/check-admin-review.mjs b/scripts/check-admin-review.mjs index aa63922..c599710 100644 --- a/scripts/check-admin-review.mjs +++ b/scripts/check-admin-review.mjs @@ -1,6 +1,41 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { buildAdminReviewState } from '../pages/admin/admin-review.js'; +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); +const adminReviewSource = readFileSync(join(rootDir, 'pages/admin/admin-review.js'), 'utf8'); +const stateStart = adminReviewSource.indexOf('export function buildAdminReviewState'); +assert.notEqual(stateStart, -1, 'Admin review should export buildAdminReviewState.'); +const adminReviewStateSource = adminReviewSource.slice(stateStart); + +assert.match( + adminReviewSource, + /function buildAdminSummary\(posts\) \{[\s\S]*?posts\.forEach\(\(post\) => \{[\s\S]*?filterCounts\.needs_review[\s\S]*?filterCounts\.reported[\s\S]*?filterCounts\.closed[\s\S]*?stats\.hidden[\s\S]*?\}\);[\s\S]*?return \{ filterCounts, stats \};[\s\S]*?\}/, + 'Admin review filter counts and stats should be summarized in one pass.' +); +assert.match( + adminReviewStateSource, + /const summary = buildAdminSummary\(decoratedPosts\);[\s\S]*?count: summary\.filterCounts\[item\.value\] \|\| 0[\s\S]*?stats: summary\.stats/, + 'buildAdminReviewState should use the one-pass summary for filter counts and stats.' +); +assert.doesNotMatch( + adminReviewSource, + /function countForFilter\(/, + 'Admin review should not keep a per-filter full-list scan helper.' +); +assert.equal( + (adminReviewStateSource.match(/decoratedPosts\s*\.\s*filter/g) || []).length, + 1, + 'buildAdminReviewState should only filter decoratedPosts for visiblePosts, not for counts or stats.' +); +assert.doesNotMatch( + adminReviewStateSource, + /decoratedPosts\s*\.\s*filter\(\(post\) => (needsReview\(post\)|post\.status|post\.reportCount|filterPost\(post, item\.value\))/, + 'Admin review stats should not rescan decoratedPosts for each metric.' +); + const now = new Date('2026-06-13T08:00:00Z').getTime(); Date.now = () => now; diff --git a/scripts/check-cloud-comment-order.mjs b/scripts/check-cloud-comment-order.mjs new file mode 100644 index 0000000..46c5fae --- /dev/null +++ b/scripts/check-cloud-comment-order.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); +const cloudFunctionPath = join(rootDir, 'cloudfunctions/posts/index.js'); +const source = readFileSync(cloudFunctionPath, 'utf8'); + +function extractFunction(name) { + const declaration = `async function ${name}`; + const start = source.indexOf(declaration); + assert.notEqual(start, -1, `Missing ${declaration}`); + + const bodyStart = source.indexOf('{', start); + assert.notEqual(bodyStart, -1, `Missing body for ${name}`); + + let depth = 0; + for (let index = bodyStart; index < source.length; index += 1) { + const char = source[index]; + if (char === '{') { + depth += 1; + } else if (char === '}') { + depth -= 1; + if (depth === 0) { + return source.slice(bodyStart + 1, index); + } + } + } + + assert.fail(`Could not find end of ${name}`); +} + +const listCommentsBody = extractFunction('listComments'); + +const collectionIndex = listCommentsBody.indexOf('db.collection(COMMENTS_COLLECTION)'); +const whereIndex = listCommentsBody.indexOf('.where({', collectionIndex); +const orderIndex = listCommentsBody.indexOf(".orderBy('createdAt', 'desc')", whereIndex); +const limitIndex = listCommentsBody.indexOf('.limit(MAX_COMMENTS_PER_POST)', whereIndex); +const getIndex = listCommentsBody.indexOf('.get()', limitIndex); + +assert.notEqual(collectionIndex, -1, 'listComments must query COMMENTS_COLLECTION'); +assert.notEqual(whereIndex, -1, 'listComments must filter comments before ordering'); +assert.notEqual(orderIndex, -1, 'listComments must order by createdAt desc before limiting'); +assert.notEqual(limitIndex, -1, 'listComments must cap comments with MAX_COMMENTS_PER_POST'); +assert.notEqual(getIndex, -1, 'listComments must execute the comment query'); +assert.ok(whereIndex < orderIndex, 'listComments must apply where before orderBy'); +assert.ok(orderIndex < limitIndex, 'listComments must order comments before limit'); +assert.ok(limitIndex < getIndex, 'listComments must limit before get'); +assert.match( + listCommentsBody, + /\.sort\(\(a,\s*b\)\s*=>\s*b\.createdAt\s*-\s*a\.createdAt\)/, + 'listComments should keep defensive newest-first sorting after normalization' +); + +console.log('Cloud comment order checks passed.'); diff --git a/scripts/check-detail-action-guards.mjs b/scripts/check-detail-action-guards.mjs new file mode 100644 index 0000000..d8dfff4 --- /dev/null +++ b/scripts/check-detail-action-guards.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); +const detailJs = readFileSync(join(rootDir, 'pages/detail/detail.js'), 'utf8'); +const detailWxml = readFileSync(join(rootDir, 'pages/detail/detail.wxml'), 'utf8'); +const storeJs = readFileSync(join(rootDir, 'utils/store.js'), 'utf8'); + +assert.match(detailJs, /busyAction:\s*''/, 'Detail page should track the active trust action.'); +assert.match(detailJs, /resolving:\s*false/, 'Detail page should track resolve submission state.'); +assert.match( + detailJs, + /async react\(event\)[\s\S]*?if \(this\.data\.busyAction\)[\s\S]*?this\.setData\(\{ busyAction: action \}\);[\s\S]*?try \{[\s\S]*?await reactToPost[\s\S]*?\} catch \(error\)[\s\S]*?\} finally \{[\s\S]*?busyAction: ''[\s\S]*?\}/, + 'react() should guard against repeated taps and recover from failed reactions.' +); +assert.match( + detailJs, + /this\.setData\(\{ resolving: true \}\);[\s\S]*?try \{[\s\S]*?await resolvePost[\s\S]*?\} catch \(error\)[\s\S]*?\} finally \{[\s\S]*?resolving: false[\s\S]*?\}/, + 'resolve() should show busy state and recover from failed close attempts.' +); +assert.match( + detailJs, + /if \(this\.data\.resolving\)[\s\S]*?this\.setData\(\{ resolving: true \}\);[\s\S]*?wx\.showModal/, + 'resolve() should lock before opening the confirmation modal.' +); +assert.match( + detailJs, + /if \(!result\.confirm\) \{[\s\S]*?this\.setData\(\{ resolving: false \}\);[\s\S]*?fail: \(\) => \{[\s\S]*?this\.setData\(\{ resolving: false \}\);/, + 'resolve() should release the lock when the modal is cancelled or fails to open.' +); +assert.match( + detailWxml, + /loading="\{\{busyAction === 'confirm'\}\}"[\s\S]*?disabled="\{\{post\.confirmedByMe \|\| busyAction\}\}"/, + 'Confirm button should reflect busyAction while a trust action is running.' +); +assert.match( + detailWxml, + /loading="\{\{busyAction === 'stale'\}\}"[\s\S]*?disabled="\{\{post\.staledByMe \|\| busyAction\}\}"/, + 'Stale button should reflect busyAction while a trust action is running.' +); +assert.match( + detailWxml, + /loading="\{\{busyAction === 'report'\}\}"[\s\S]*?disabled="\{\{post\.reportedByMe \|\| busyAction\}\}"/, + 'Report button should reflect busyAction while a trust action is running.' +); +assert.match( + detailWxml, + /loading="\{\{resolving\}\}"[\s\S]*?disabled="\{\{resolving\}\}"[\s\S]*?\{\{resolving \? '关闭中' : post\.resolveText\}\}/, + 'Resolve button should expose a busy state while closing a post.' +); + +const reactToPostMatch = storeJs.match(/export async function reactToPost\(id, action\) \{([\s\S]*?)\n\}\n\nexport async function resolvePost/); +assert.ok(reactToPostMatch, 'reactToPost() should exist before resolvePost().'); +const reactToPostBody = reactToPostMatch[1]; +assert.match( + reactToPostBody, + /if \(hasReactedToPost\(id, action\)\)[\s\S]*?const post = await findPost\(id\);[\s\S]*?if \(hasReactedToPost\(id, action\)\)/, + 'reactToPost() should check duplicate local reactions both before and after async post lookup.' +); + +console.log('Detail action guard checks passed.'); diff --git a/scripts/check-devtools-readiness.mjs b/scripts/check-devtools-readiness.mjs index 7796592..ff20958 100644 --- a/scripts/check-devtools-readiness.mjs +++ b/scripts/check-devtools-readiness.mjs @@ -12,6 +12,11 @@ const requiredFiles = [ 'scripts/check-comment-relay.mjs', 'scripts/check-action-relay.mjs', 'scripts/check-receiver-conversion.mjs', + 'scripts/check-detail-action-guards.mjs', + 'scripts/check-store-permission-guards.mjs', + 'scripts/check-cloud-comment-order.mjs', + 'scripts/check-admin-review.mjs', + 'scripts/check-performance-guards.mjs', 'scripts/check-viral-journey-evidence.mjs', 'scripts/check-viral-journey-manual-evidence.mjs', 'scripts/check-viral-manual-summary-integrity.mjs', @@ -199,6 +204,11 @@ runCheck('scripts/check-publish-spread.mjs', 'post-publish spread plan check'); runCheck('scripts/check-comment-relay.mjs', 'comment relay prompt check'); runCheck('scripts/check-action-relay.mjs', 'action relay prompt check'); runCheck('scripts/check-receiver-conversion.mjs', 'receiver conversion relay check'); +runCheck('scripts/check-detail-action-guards.mjs', 'detail action busy guard check'); +runCheck('scripts/check-store-permission-guards.mjs', 'store permission guard check'); +runCheck('scripts/check-cloud-comment-order.mjs', 'cloud comment newest-first query check'); +runCheck('scripts/check-admin-review.mjs', 'admin review performance/state helper check'); +runCheck('scripts/check-performance-guards.mjs', 'performance regression guard'); runCheck('scripts/check-viral-journey-evidence.mjs', 'viral journey evidence model check'); console.log('Running viral journey manual evidence gate. This scans ignored local result files only when they exist.'); runCheck('scripts/check-viral-journey-manual-evidence.mjs', 'viral journey manual evidence gate'); diff --git a/scripts/check-performance-guards.mjs b/scripts/check-performance-guards.mjs new file mode 100644 index 0000000..1d65846 --- /dev/null +++ b/scripts/check-performance-guards.mjs @@ -0,0 +1,311 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); +const storePath = join(rootDir, 'utils/store.js'); +const storeSource = readFileSync(storePath, 'utf8'); + +assert.match( + storeSource, + /function postsCacheSource\(options = \{\}, allowExplicitSource = false\) \{[\s\S]*?if \(allowExplicitSource && options\.source\) \{[\s\S]*?return options\.source;[\s\S]*?\}[\s\S]*?if \(options\.localOnly\) \{[\s\S]*?return 'local';[\s\S]*?\}[\s\S]*?return shouldUseCloud\(\) \? 'cloud' : 'local';[\s\S]*?\}/, + 'posts cache should track whether data came from local storage or cloud.' +); +assert.match( + storeSource, + /function rememberPosts\(posts, options = \{\}\) \{[\s\S]*?source: postsCacheSource\(options, true\)[\s\S]*?\}/, + 'Only internal cache writes should be allowed to pass an explicit source.' +); +assert.match( + storeSource, + /const source = postsCacheSource\(options\);[\s\S]*?postsLoadPromise = fetchPosts\(options\)[\s\S]*?return rememberPosts\(posts, \{[\s\S]*?includeHidden: Boolean\(options\.includeHidden\),[\s\S]*?source[\s\S]*?\}\);/, + 'Cloud/local fetch results should write cache with the derived source, not caller-provided options.' +); +assert.match( + storeSource, + /const sourcePosts = options\.includeHidden[\s\S]*?\? posts[\s\S]*?: posts\.filter\(\(post\) => post\.status !== 'hidden'\);[\s\S]*?return sourcePosts[\s\S]*?\.map\(\(post\) => derivePost\(post, now, center\)\)/, + 'Visible post lists should filter hidden raw posts before distance derivation and sorting.' +); +assert.doesNotMatch( + storeSource, + /export async function listPosts[\s\S]*?\.filter\(\(post\) => post\.status !== 'hidden'\)[\s\S]*?\.slice\(0, config\.maxVisiblePosts\)/, + 'listPosts should not filter hidden posts after deriving distance for them.' +); +assert.match( + storeSource, + /export async function listPosts\(center = config\.defaultCenter, options = \{\}\) \{[\s\S]*?sortedDerivedPosts\(center, \{ \.\.\.options, includeHidden: false \}\)/, + 'Normal listPosts callers should not be able to opt into hidden posts.' +); +assert.match( + storeSource, + /function cachedPosts\(options = \{\}\) \{[\s\S]*?const source = postsCacheSource\(options\);[\s\S]*?if \(postsCache\.source !== source\) \{[\s\S]*?return null;[\s\S]*?\}/, + 'cachedPosts should not serve localOnly data to cloud-backed reads.' +); +assert.match( + storeSource, + /function rememberCloudPost\(post\) \{[\s\S]*?if \(!post \|\| !postsCache\.data \|\| postsCache\.source !== 'cloud'\) \{[\s\S]*?return;[\s\S]*?\}/, + 'Cloud post updates should only mutate cloud-sourced cache entries.' +); +assert.match( + storeSource, + /function removeCachedPost\(id\) \{[\s\S]*?if \(!postsCache\.data \|\| postsCache\.source !== 'cloud'\) \{[\s\S]*?return;[\s\S]*?\}/, + 'Cloud post removals should only mutate cloud-sourced cache entries.' +); +assert.match( + storeSource, + /if \(options\.localOnly\) \{[\s\S]*?const cached = cachedPosts\(options\);[\s\S]*?if \(cached\) \{[\s\S]*?return cached;[\s\S]*?\}[\s\S]*?return rememberPosts\(seedPosts\(\), \{ includeHidden: true, source: 'local' \}\);[\s\S]*?\}/, + 'localOnly post loads should use the short-lived posts cache before reading storage.' +); +assert.match( + storeSource, + /if \(options\.localOnly\) \{[\s\S]*?return rememberPosts\(seedPosts\(\), \{ includeHidden: true, source: 'local' \}\);[\s\S]*?\}/, + 'localOnly cache misses should always be recorded as local data even when cloud is available.' +); +assert.doesNotMatch( + storeSource, + /if \(options\.localOnly\) \{\s*return seedPosts\(\);\s*\}/, + 'localOnly post loads should not always read wx storage.' +); + +const now = Date.now(); +const center = { latitude: 39.9, longitude: 116.3 }; +const posts = [ + { + id: 'perf_post_1', + markerId: 1, + title: '性能检查', + body: '缓存读取', + category: 'help_needed', + placeName: '测试点', + latitude: 39.9, + longitude: 116.3, + status: 'active', + confirmations: 0, + lastConfirmedAt: 0, + staleCount: 0, + reportCount: 0, + createdAt: now - 1000, + expiresAt: now + 3600000, + publisherId: 'owner_user', + publisher: '发布者' + } +]; + +let postsStorageReads = 0; +let storage = { + posts, + user: { + id: 'owner_user', + role: 'user', + isGuest: false + } +}; + +let cloudCalls = 0; +globalThis.getApp = () => ({ globalData: { cloudReady: true } }); +globalThis.wx = { + getStorageSync(key) { + if (key === 'posts') { + postsStorageReads += 1; + } + return storage[key]; + }, + setStorageSync(key, value) { + storage[key] = value; + }, + cloud: { + callFunction() { + cloudCalls += 1; + return Promise.resolve({ + result: { + ok: true, + data: { + posts: [ + { + ...posts[0], + id: 'cloud_perf_post', + title: '云端性能检查' + } + ] + } + } + }); + } + } +}; + +const storeModuleUrl = `${pathToFileURL(storePath).href}?performance=${Date.now()}`; +const { listPosts } = await import(storeModuleUrl); + +const first = await listPosts(center, { localOnly: true }); +const second = await listPosts(center, { localOnly: true }); + +assert.equal(first.length, 1); +assert.equal(second.length, 1); +assert.equal(postsStorageReads, 1, 'localOnly listPosts should reuse cache for repeated reads.'); + +const cloudPosts = await listPosts(center); + +assert.equal(cloudCalls, 1, 'localOnly cache should not satisfy a later cloud-backed listPosts call.'); +assert.equal(cloudPosts[0].id, 'cloud_perf_post'); + +postsStorageReads = 0; +const localDespiteConflictingSource = await listPosts(center, { localOnly: true, source: 'cloud' }); +assert.equal(postsStorageReads, 1, 'localOnly should ignore a conflicting caller-provided source option.'); +assert.equal(localDespiteConflictingSource[0].id, 'perf_post_1'); + +postsStorageReads = 0; +cloudCalls = 0; +storage = { + posts, + user: { + id: 'owner_user', + nickname: '附近用户', + avatarUrl: '', + role: 'user', + isGuest: false, + loggedInAt: now + } +}; + +globalThis.wx.cloud = { + callFunction({ data }) { + cloudCalls += 1; + if (data.action === 'create') { + return Promise.resolve({ + result: { + ok: true, + data: { + post: { + ...posts[0], + id: 'cloud_created_post', + title: '云端新帖', + publisherId: 'owner_user' + } + } + } + }); + } + return Promise.resolve({ + result: { + ok: true, + data: { posts: [] } + } + }); + } +}; + +const secondStoreModuleUrl = `${pathToFileURL(storePath).href}?performance=${Date.now()}-cloud-write`; +const { createPost, listPosts: listPostsAfterCloudWrite } = await import(secondStoreModuleUrl); + +await listPostsAfterCloudWrite(center, { localOnly: true }); +await createPost({ + title: '云端新帖', + body: '云端写入不应污染 localOnly cache', + category: 'help_needed', + latitude: center.latitude, + longitude: center.longitude, + expiryHours: 6, + imageUrls: [] +}); +const localAfterCloudCreate = await listPostsAfterCloudWrite(center, { localOnly: true }); + +assert.equal(cloudCalls, 1, 'cloud create should call the posts cloud function once.'); +assert.deepEqual( + localAfterCloudCreate.map((post) => post.id), + ['perf_post_1'], + 'cloud writes should not inject cloud-only posts into a localOnly cache.' +); + +postsStorageReads = 0; +cloudCalls = 0; +storage = { + posts, + user: { + id: 'owner_user', + role: 'user', + isGuest: false + } +}; +globalThis.wx.cloud = { + callFunction() { + cloudCalls += 1; + return Promise.resolve({ + result: { + ok: true, + data: { + posts: [ + { + ...posts[0], + id: 'cloud_after_conflict_post', + title: '云端仍优先' + } + ] + } + } + }); + } +}; + +const thirdStoreModuleUrl = `${pathToFileURL(storePath).href}?performance=${Date.now()}-source-conflict`; +const { listPosts: listPostsWithConflictingSource } = await import(thirdStoreModuleUrl); + +await listPostsWithConflictingSource(center, { localOnly: true }); +const cloudDespiteConflictingSource = await listPostsWithConflictingSource(center, { source: 'local' }); + +assert.equal(cloudCalls, 1, 'cloud-ready listPosts should ignore a caller-provided local source option.'); +assert.equal(cloudDespiteConflictingSource[0].id, 'cloud_after_conflict_post'); + +postsStorageReads = 0; +const localAfterConflictingCloudRead = await listPostsWithConflictingSource(center, { localOnly: true }); +assert.equal( + postsStorageReads, + 1, + 'a cloud read with caller-provided source should not poison the later localOnly cache.' +); +assert.equal(localAfterConflictingCloudRead[0].id, 'perf_post_1'); + +storage = { + posts: [ + posts[0], + { + ...posts[0], + id: 'hidden_perf_post', + status: 'hidden', + title: '隐藏性能检查' + } + ], + user: storage.user +}; +globalThis.getApp = () => ({ globalData: { cloudReady: false } }); + +const fourthStoreModuleUrl = `${pathToFileURL(storePath).href}?performance=${Date.now()}-hidden-filter`; +const { + listPosts: listVisiblePosts, + listAllPosts +} = await import(fourthStoreModuleUrl); + +const visibleOnly = await listVisiblePosts(center, { localOnly: true }); +const visibleEvenWithHiddenOption = await listVisiblePosts(center, { + localOnly: true, + includeHidden: true +}); +const includeHidden = await listAllPosts(center); + +assert.deepEqual( + visibleOnly.map((post) => post.id), + ['perf_post_1'], + 'normal listPosts should still omit hidden posts after pre-filtering.' +); +assert.deepEqual( + visibleEvenWithHiddenOption.map((post) => post.id), + ['perf_post_1'], + 'normal listPosts should ignore includeHidden and keep hidden posts out of public lists.' +); +assert.ok( + includeHidden.some((post) => post.id === 'hidden_perf_post'), + 'listAllPosts should still include hidden posts for admin-style surfaces.' +); + +console.log('Performance guard checks passed.'); diff --git a/scripts/check-store-permission-guards.mjs b/scripts/check-store-permission-guards.mjs new file mode 100644 index 0000000..efc5764 --- /dev/null +++ b/scripts/check-store-permission-guards.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; + +import { resolvePost } from '../utils/store.js'; + +const POST_ID = 'permission_post'; +const now = Date.now(); +let storage = {}; + +function basePost(overrides = {}) { + return { + id: POST_ID, + markerId: 9001, + title: '权限检查任务', + body: '验证本地关闭权限', + category: 'help_needed', + intent: '', + placeName: '测试地点', + latitude: 39.9, + longitude: 116.3, + status: 'active', + confirmations: 0, + lastConfirmedAt: 0, + staleCount: 0, + reportCount: 0, + createdAt: now - 1000, + expiresAt: now + 3600000, + publisherId: 'owner_user', + publisher: '发布者', + publisherAvatarUrl: '', + publisherRole: 'user', + isMine: false, + ...overrides + }; +} + +function user(overrides = {}) { + return { + id: 'viewer_user', + nickname: '附近用户', + avatarUrl: '', + role: 'user', + authSource: '', + isGuest: false, + profileCompleted: false, + loggedInAt: now, + ...overrides + }; +} + +function resetStorage(currentUser, post = basePost()) { + storage = { + user: currentUser, + posts: [post] + }; + globalThis.wx = { + getStorageSync(key) { + return storage[key]; + }, + setStorageSync(key, value) { + storage[key] = value; + } + }; +} + +function storedPost() { + return storage.posts.find((post) => post.id === POST_ID); +} + +resetStorage(user({ id: 'not_owner' }), basePost({ isMine: true })); +await assert.rejects( + () => resolvePost(POST_ID), + (error) => error && error.code === 'FORBIDDEN' && /发布者或管理员/.test(error.message) +); +assert.equal(storedPost().status, 'active'); + +resetStorage(user({ id: 'owner_user' })); +const ownerPost = await resolvePost(POST_ID); +assert.equal(ownerPost.status, 'resolved'); +assert.equal(storedPost().status, 'resolved'); + +resetStorage(user({ + id: 'admin_user', + role: 'admin', + authSource: 'cloud' +})); +const adminPost = await resolvePost(POST_ID); +assert.equal(adminPost.status, 'resolved'); +assert.equal(storedPost().status, 'resolved'); + +console.log('Store permission guard checks passed.'); diff --git a/utils/auth.js b/utils/auth.js index cffa196..503ee01 100644 --- a/utils/auth.js +++ b/utils/auth.js @@ -117,6 +117,17 @@ function saveCloudUser(previous, role, openid) { }); } +function saveAdminCheckFallbackUser(previous) { + // A failed admin check should revoke stale admin state, but must not turn a guest into a logged-in user. + return saveUser({ + ...previous, + role: 'user', + authSource: '', + isGuest: Boolean(previous.isGuest), + loggedInAt: previous.isGuest ? previous.loggedInAt || 0 : previous.loggedInAt || Date.now() + }); +} + function adminCheckInfo(data = {}, fallbackReason = '') { const reason = data.reason || fallbackReason; return { @@ -155,7 +166,7 @@ export function formatAdminRoleError(error) { export async function refreshAdminRole() { const previous = getCurrentUser(); if (!canUseCloudRole()) { - const user = saveCloudUser(previous, 'user'); + const user = saveAdminCheckFallbackUser(previous); return { ok: false, message: '请先配置云开发环境', @@ -169,7 +180,7 @@ export async function refreshAdminRole() { name: 'getMyRole' }); } catch (error) { - const user = saveCloudUser(previous, 'user'); + const user = saveAdminCheckFallbackUser(previous); const check = adminCheckInfo(formatAdminRoleError(error)); return { ok: false, @@ -179,13 +190,21 @@ export async function refreshAdminRole() { }; } const data = result.result || {}; - const nextRole = data.role === 'admin' ? 'admin' : 'user'; - const user = saveCloudUser(previous, nextRole, data.openid); + if (data.role !== 'admin' || data.ok !== true) { + const user = saveAdminCheckFallbackUser(previous); + const check = adminCheckInfo(data); + return { + ok: false, + message: check.reason || '当前微信不是管理员', + check, + user + }; + } + const user = saveCloudUser(previous, 'admin', data.openid); const check = adminCheckInfo(data); - const message = nextRole === 'admin' ? '' : (check.reason || '当前微信不是管理员'); return { - ok: nextRole === 'admin', - message, + ok: true, + message: '', check, user }; diff --git a/utils/store.js b/utils/store.js index fa9535d..64e62c1 100644 --- a/utils/store.js +++ b/utils/store.js @@ -15,14 +15,29 @@ let postsLoadPromise = null; let postsCache = { data: null, expiresAt: 0, - includeHidden: false + includeHidden: false, + source: '' }; +function postsCacheSource(options = {}, allowExplicitSource = false) { + if (allowExplicitSource && options.source) { + return options.source; + } + if (options.localOnly) { + return 'local'; + } + return shouldUseCloud() ? 'cloud' : 'local'; +} + function cachedPosts(options = {}) { const needsHidden = Boolean(options.includeHidden); + const source = postsCacheSource(options); if (!postsCache.data || postsCache.expiresAt <= Date.now()) { return null; } + if (postsCache.source !== source) { + return null; + } if (needsHidden && !postsCache.includeHidden) { return null; } @@ -33,13 +48,14 @@ function rememberPosts(posts, options = {}) { postsCache = { data: Array.isArray(posts) ? posts : [], expiresAt: Date.now() + POSTS_CACHE_TTL_MS, - includeHidden: Boolean(options.includeHidden) + includeHidden: Boolean(options.includeHidden), + source: postsCacheSource(options, true) }; return postsCache.data; } function rememberCloudPost(post) { - if (!post || !postsCache.data) { + if (!post || !postsCache.data || postsCache.source !== 'cloud') { return; } const existingIndex = postsCache.data.findIndex((item) => item.id === post.id); @@ -54,7 +70,7 @@ function rememberCloudPost(post) { } function removeCachedPost(id) { - if (!postsCache.data) { + if (!postsCache.data || postsCache.source !== 'cloud') { return; } postsCache = { @@ -133,7 +149,7 @@ function seedPosts() { function savePosts(posts) { wx.setStorageSync(STORAGE_KEY, posts); - rememberPosts(posts, { includeHidden: true }); + rememberPosts(posts, { includeHidden: true, source: 'local' }); } function localPostById(id) { @@ -189,7 +205,13 @@ async function fetchPosts(options = {}) { async function loadPosts(options = {}) { if (options.localOnly) { - return seedPosts(); + if (!options.force) { + const cached = cachedPosts(options); + if (cached) { + return cached; + } + } + return rememberPosts(seedPosts(), { includeHidden: true, source: 'local' }); } if (!options.force) { @@ -202,15 +224,19 @@ async function loadPosts(options = {}) { } } + const source = postsCacheSource(options); postsLoadPromise = fetchPosts(options) .then((posts) => { postsLoadPromise = null; - return rememberPosts(posts, options); + return rememberPosts(posts, { + includeHidden: Boolean(options.includeHidden), + source + }); }) .catch((error) => { postsLoadPromise = null; addDiagnostic('store.loadPosts', error); - return rememberPosts(seedPosts(), { includeHidden: true }); + return rememberPosts(seedPosts(), { includeHidden: true, source: 'local' }); }); return postsLoadPromise; } @@ -281,6 +307,16 @@ function postClosedError() { return error; } +function forbiddenError(message) { + const error = new Error(message || '没有权限执行此操作'); + error.code = 'FORBIDDEN'; + return error; +} + +function canResolveLocalPost(post, user) { + return Boolean(post && user && (isAdmin(user) || post.publisherId === user.id)); +} + function cleanCommentBody(body) { const text = String(body || '').trim(); if (!text) { @@ -331,7 +367,10 @@ function viewablePost(post, center = config.defaultCenter) { async function sortedDerivedPosts(center, options = {}) { const now = Date.now(); const posts = await loadPosts(options); - return posts + const sourcePosts = options.includeHidden + ? posts + : posts.filter((post) => post.status !== 'hidden'); + return sourcePosts .map((post) => derivePost(post, now, center)) .sort((a, b) => statusRank(a.status) - statusRank(b.status) || b.createdAt - a.createdAt); } @@ -342,10 +381,9 @@ export async function listAllPosts(center = config.defaultCenter) { } export async function listPosts(center = config.defaultCenter, options = {}) { - const posts = await sortedDerivedPosts(center, options); - return posts - .filter((post) => post.status !== 'hidden') - .slice(0, config.maxVisiblePosts); + // Hidden posts are an admin-only surface; callers cannot opt normal lists into them. + const posts = await sortedDerivedPosts(center, { ...options, includeHidden: false }); + return posts.slice(0, config.maxVisiblePosts); } export async function getPost(id) { @@ -553,6 +591,9 @@ export async function reactToPost(id, action) { return getPost(id); } const post = await findPost(id); + if (hasReactedToPost(id, action)) { + return viewablePost(post); + } if (!post || !canTrustReact(post, now)) { return viewablePost(post); } @@ -591,6 +632,10 @@ export async function resolvePost(id) { const now = Date.now(); const post = await findPost(id); + const user = getCurrentUser(); + if (post && !canResolveLocalPost(post, user)) { + throw forbiddenError('只有发布者或管理员可关闭'); + } let patch = null; if (post && canTrustReact(post, now)) { patch = { status: 'resolved' };