diff --git a/docs/technical/README.md b/docs/technical/README.md index 113f5a38..42052f2c 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,7 +4,7 @@ ## 文档列表 -- [WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md](./WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md):记录微信小程序 `web-view` 壳的最小接入范围、需要填写的 H5 业务域名、微信后台配置和后续原生化边界。 +- [WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md](./WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md):记录微信小程序 `web-view` 壳的最小接入范围、需要填写的 H5 业务域名、微信后台配置、`npm run check:wechat-miniprogram-auth` 可重复登录链路 smoke 和后续原生化边界。 - [BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”后端 DDD 技术方案,明确 `server-rs + Axum + SpacetimeDB` 分层边界、shared contracts、作品配置、runtime run、派生成绩、排行榜、`work_play_start` 埋点、migration/绑定生成策略,以及不保存原始麦克风音频的隐私与反作弊约束。 - [BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”2D 浏览器 runtime 技术方案,明确 Phaser + TypeScript + Vite 选型、纯 TS simulation 与 Phaser renderer/DOM HUD 边界、Web Audio 输入适配、移动端权限降级和后续测试验证命令。 - [PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md](./PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md):记录直接访问公开作品详情深链时作品不存在或已下架的回首页修复,避免关闭提示后停在 `work-detail` 空状态白屏。 diff --git a/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md b/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md index af072c77..d7d793cf 100644 --- a/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md +++ b/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md @@ -143,6 +143,20 @@ https://你的H5业务域名/#auth_provider=wechat&auth_token=<系统JWT>&auth_b ## 7. 验收口径 +可重复自动化 smoke: + +```bash +npm run check:wechat-miniprogram-auth +``` + +该命令固定覆盖三段链路: + +1. 静态确认 `miniprogram/pages/web-view/index.js` 会请求 `/api/auth/wechat/miniprogram-login`,携带 `mini_program / wechat_mini_program` 客户端来源头,并把 `auth_provider/auth_token/auth_binding_status` 拼入 H5 hash。 +2. 运行 `api-server` 定向测试 `wechat_miniprogram_login_returns_system_token_and_marks_session_source`,断言小程序登录返回 `token/bindingStatus/user`、写入 refresh cookie,并且 `/api/auth/sessions` 能看到 `clientType=mini_program`、`clientRuntime=wechat_mini_program`、`miniProgramAppId`。 +3. 运行前端 `authService` 定向测试,断言 `consumeAuthCallbackResult()` 会消费 `#auth_provider=wechat&auth_token=...&auth_binding_status=...`、保存 access token,并清理地址栏 hash。 + +手工联调仍按以下口径确认真实微信与域名配置: + 1. 微信开发者工具打开项目根目录后,识别 `miniprogram/` 为小程序源码目录。 2. 未填写 `WEB_VIEW_ENTRY_URL` 或 `API_BASE_URL` 时,页面显示配置提示,不出现空白页。 3. 填写已配置业务域名后,小程序先请求 `/api/auth/wechat/miniprogram-login`。 diff --git a/package.json b/package.json index 71d5ba5a..94592926 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "assets:child-motion-demo": "node scripts/generate-child-motion-demo-assets.mjs", "check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs", "check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs", + "check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs", "check:server-rs-ddd": "node scripts/check-server-rs-ddd-boundaries.mjs", "lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0", "lint:guardrails": "npm run lint:eslint", diff --git a/scripts/check-wechat-miniprogram-auth-smoke.mjs b/scripts/check-wechat-miniprogram-auth-smoke.mjs new file mode 100644 index 00000000..9d87e3f5 --- /dev/null +++ b/scripts/check-wechat-miniprogram-auth-smoke.mjs @@ -0,0 +1,114 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = process.cwd(); +const failures = []; + +const smokeSteps = [ + { + label: '小程序壳请求与 hash 回跳静态检查', + run: checkMiniProgramShell, + }, + { + label: 'api-server 小程序登录与会话来源测试', + run: () => + runCommand('cargo', [ + 'test', + '-p', + 'api-server', + 'wechat_miniprogram_login_returns_system_token_and_marks_session_source', + '--manifest-path', + 'server-rs/Cargo.toml', + '--', + '--nocapture', + ]), + }, + { + label: 'H5 auth hash 消费测试', + run: () => + runCommand(process.execPath, [ + fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url)), + 'run', + 'src/services/authService.test.ts', + '-t', + 'consumes auth callback hash and persists the returned access token', + ]), + }, +]; + +for (const step of smokeSteps) { + console.log(`[wechat-miniprogram-auth-smoke] ${step.label}`); + step.run(); +} + +if (failures.length > 0) { + console.error('\n[wechat-miniprogram-auth-smoke] 未通过:'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log('\n[wechat-miniprogram-auth-smoke] 通过'); + +function checkMiniProgramShell() { + const shellPath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js'); + const authServiceTestPath = join(repoRoot, 'src', 'services', 'authService.test.ts'); + + ensureNeedles(shellPath, [ + '/api/auth/wechat/miniprogram-login', + "'x-client-type': MINI_PROGRAM_CLIENT_TYPE", + "'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME", + 'auth_provider', + 'auth_token', + 'auth_binding_status', + 'bindingStatus', + ]); + + // 中文注释:这里锁定 H5 消费回跳 hash 的真实测试输入,避免只检查实现文本。 + ensureNeedles(authServiceTestPath, [ + '#auth_provider=wechat&auth_token=jwt-callback-token&auth_binding_status=pending_bind_phone', + 'consumeAuthCallbackResult()', + "bindingStatus: 'pending_bind_phone'", + "expect(getStoredAccessToken()).toBe('jwt-callback-token')", + ]); +} + +function ensureNeedles(relativeOrFullPath, needles) { + if (!existsSync(relativeOrFullPath)) { + failures.push(`缺少文件:${relativeOrFullPath}`); + return; + } + + const content = readFileSync(relativeOrFullPath, 'utf8'); + for (const needle of needles) { + if (!content.includes(needle)) { + failures.push(`${relativeOrFullPath} 缺少内容:${needle}`); + } + } +} + +function runCommand(command, args) { + const result = spawnSync(command, args, { + cwd: repoRoot, + env: process.env, + shell: false, + stdio: 'inherit', + }); + + if (result.error) { + failures.push(`${command} 启动失败:${result.error.message}`); + return; + } + + if (result.signal) { + failures.push(`${command} 被信号终止:${result.signal}`); + return; + } + + if ((result.status ?? 0) !== 0) { + failures.push(`${command} ${args.join(' ')} 退出码 ${result.status}`); + } +} diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index f814a2b7..91d24ecd 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -3372,13 +3372,6 @@ fn match3d_bad_request( ) } -fn match3d_bad_gateway(message: impl Into) -> AppError { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "match3d", - "message": message.into(), - })) -} - fn map_match3d_client_error(error: SpacetimeClientError) -> AppError { let status = match &error { SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 37797e7f..6dd6b374 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -57,8 +57,8 @@ use spacetime_client::{ PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, - PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, - PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, + PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, + PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, @@ -2061,7 +2061,9 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft level_name: level.level_name, picture_description: level.picture_description, picture_reference: level.picture_reference, - background_music: level.background_music.map(map_puzzle_audio_asset_record_response), + background_music: level + .background_music + .map(map_puzzle_audio_asset_record_response), candidates: level .candidates .into_iter() @@ -2667,7 +2669,9 @@ fn parse_puzzle_level_records_from_module_json( level_name: level.level_name, picture_description: level.picture_description, picture_reference: level.picture_reference, - background_music: level.background_music.map(map_puzzle_audio_asset_domain_record), + background_music: level + .background_music + .map(map_puzzle_audio_asset_domain_record), candidates: level .candidates .into_iter() @@ -4608,8 +4612,7 @@ mod tests { let levels_json = serialize_puzzle_levels_response(&request_context, &[level]) .expect("levels should serialize"); - let payload: Value = - serde_json::from_str(&levels_json).expect("levels json should parse"); + let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse"); assert_eq!( payload[0]["background_music"]["audio_src"], Value::String("/generated-puzzle-assets/audio.mp3".to_string())