From 8ade75390cfd75d7293a7d9904a24cc934ef071a Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 15 May 2026 01:07:39 +0800 Subject: [PATCH] Persist api-server logs and refresh recharge balance --- .hermes/shared-memory/development-workflow.md | 2 + ...OUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md | 11 +- ...ND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md | 8 +- project.config.json | 16 +- project.private.config.json | 11 +- scripts/api-server-dev.mjs | 158 ++++++++++++++++-- scripts/api-server-dev.test.ts | 42 ++++- scripts/dev-rust-stack.sh | 59 ++++++- server-rs/crates/api-server/README.md | 1 + .../RpgEntryHomeView.recharge.test.tsx | 19 ++- src/components/rpg-entry/RpgEntryHomeView.tsx | 133 +++++++++++++-- 11 files changed, 406 insertions(+), 54 deletions(-) diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index 5f73294a..454c34ed 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -69,6 +69,8 @@ npm run dev:web npm run api-server ``` +该命令会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-.log`。完整联调入口 `npm run dev` / `npm run dev:rust` 启动的 Rust `api-server` 也会写入 `logs/api-server/api-server-dev-rust-.log`。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`。 + 查看本地 Rust/SpacetimeDB 日志: ```bash diff --git a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md index d4c8f436..d2f52ad2 100644 --- a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md +++ b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md @@ -93,7 +93,7 @@ 2. 若订单已是 `paid`,直接返回订单与账户中心快照。 3. 若订单仍是 `pending`,后端调用微信支付按商户订单号查单接口。 4. 只有微信查单返回 `trade_state = "SUCCESS"` 时,才调用统一入账 procedure 把订单改为 `paid` 并写入钱包流水或会员状态。 -5. 如果微信查单仍不是 `SUCCESS`,接口返回当前 pending 订单与账户中心快照;前端只显示“支付已提交”,不提前发放泥点或会员。 +5. 如果微信查单仍不是 `SUCCESS`,接口返回当前 pending 订单与账户中心快照;前端只在全局支付结果模态显示“支付已提交”,不提前发放泥点或会员。 响应结构: @@ -144,10 +144,11 @@ 4. 点击套餐后调用下单接口,按钮进入处理中状态;小程序环境走 native 支付页拉起 `wx.requestPayment`,支付页返回后刷新 `profileDashboard`。 - 小程序 web-view 内的 H5 只负责加载微信 JS-SDK 并通过 `wx.miniProgram.navigateTo` 跳转到 `/pages/wechat-pay/index`;实际支付必须在小程序 native 页调用 `wx.requestPayment`,不要切换为 H5 支付产品。 - native 支付页通过 `wx_pay_result=:success|cancel|fail` 回填 web-view;H5 在 `hashchange`、`focus`、`pageshow` 和 `visibilitychange` 中都会尝试消费该结果,避免小程序返回 web-view 时没有触发单一事件导致状态不刷新。 - - `success` 只表示微信客户端支付流程返回成功,前端随后调用 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` 由服务端查单确认;只有通知或服务端查单确认为 `SUCCESS` 才入账。 - - `cancel` 和 `fail` 只复位按钮、刷新账户中心并展示状态,不调用入账逻辑。 -5. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和状态反馈。 -6. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。 + - `success` 只表示微信客户端支付流程返回成功,前端随后调用 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` 由服务端查单确认;只有通知或服务端查单确认为 `SUCCESS` 才入账。 + - `cancel` 和 `fail` 只复位按钮、刷新账户中心并通过全局支付结果模态展示,不调用入账逻辑。 +5. 支付结果使用页面级全局模态展示,不写回商品卡片或账户充值弹窗内部;充值弹窗只负责套餐选择、加载失败和下单失败。 +6. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和操作状态。 +7. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。 ## 5. 验收 diff --git a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md index 81cff52e..58c3e18a 100644 --- a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md +++ b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md @@ -48,7 +48,7 @@ npm run dev:rust 5. 如果确认需要新启动 SpacetimeDB,脚本会先检测 `127.0.0.1:3101` 是否可监听;若已占用,输出占用进程并选择从 `3101` 起向后的最近可用端口,再执行 `spacetime start --data-dir server-rs/.spacetimedb/local/data --listen-addr <实际地址>`。启动成功后把实际 URL 写入 `server-rs/.spacetimedb/local/data/dev-rust-spacetime-url`,后续 publish 与 `api-server` 都使用同一个实际 URL。 6. 等待 SpacetimeDB 就绪:优先接受 `spacetime server ping http://127.0.0.1:` 输出中的 `Server is online:`;如果 Windows 下 SpacetimeDB CLI `2.1.0` 对已经监听的 standalone 仍打印 `502 Bad Gateway`,脚本会兜底请求 `http://127.0.0.1:/v1/ping`,只有该健康端点返回 `2xx` 时才放行。不能只依赖 CLI 退出码,因为 CLI 在 `502 Bad Gateway` 时也可能返回退出码 `0`。 7. 执行 `spacetime publish <本地数据库名> --server <实际 SpacetimeDB URL> --module-path server-rs/crates/spacetime-module --build-options="--debug" -c=on-conflict --yes`,确保 publish 仍由 SpacetimeDB CLI 负责构建和发布模块,同时使用 debug 构建参数降低本地开发等待时间;当前开发阶段允许新版模块表结构变化且发生 schema 冲突时清除旧模块数据。 -8. 启动 `api-server` 前先检测默认 API 端口 `8082` 是否可监听;若已占用,输出占用进程并选择从 `8082` 起向后的最近可用端口。随后注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*`,启动默认 debug profile 的 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。 +8. 启动 `api-server` 前先检测默认 API 端口 `8082` 是否可监听;若已占用,输出占用进程并选择从 `8082` 起向后的最近可用端口。随后注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*`,启动默认 debug profile 的 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。本地启动器会保留终端实时输出,并把同一份 `cargo` / `api-server` 输出持久化到 `logs/api-server/`。 9. 等待 `http://127.0.0.1:/healthz` 返回 HTTP 响应后再启动 Vite,避免前端初始化请求早于 Rust `api-server` 监听完成并在终端刷出 `ECONNREFUSED 127.0.0.1:`。 10. 注入 `RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。 11. 任一子进程退出时,脚本回收其余子进程。 @@ -119,6 +119,12 @@ npm run dev:rust:logs -- --follow 3. 默认输出到 `logs/spacetime/-.log`,并通过 `tee` 同步显示在终端。 4. `--follow` 仅用于本地追踪,会持续追加到同一个输出文件;停止时用 `Ctrl+C`。 +api-server 本地持久化日志: + +1. `npm run api-server` 默认写入 `logs/api-server/api-server-.log`,同时继续把同一份输出显示在当前终端。 +2. `npm run dev` / `npm run dev:rust` 中由脚本启动的 Rust `api-server` 默认写入 `logs/api-server/api-server-dev-rust-.log`;等待 `/healthz` 失败时,脚本会自动输出该日志最后 80 行。 +3. 如需固定日志文件,可设置 `GENARRATIVE_API_SERVER_LOG_FILE=logs/api-server/local.log`;如只需更换目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR=logs/api-server`。相对路径都按仓库根目录解析。 + 联调排错补充: 1. 如果首页公开广场出现 `上游服务请求失败`,优先检查 `api-server` 错误详情里的 `ws://.../v1/database//subscribe` 是否指向了未发布的库。 diff --git a/project.config.json b/project.config.json index f526e96e..c61c3763 100644 --- a/project.config.json +++ b/project.config.json @@ -12,7 +12,16 @@ "outputPath": "" }, "useCompilerPlugins": false, - "minifyWXML": true + "minifyWXML": true, + "compileWorklet": false, + "uploadWithSourceMap": true, + "packNpmManually": false, + "minifyWXSS": true, + "localPlugins": false, + "disableUseStrict": false, + "condition": false, + "swc": false, + "disableSWC": true }, "compileType": "miniprogram", "miniprogramRoot": "miniprogram/", @@ -22,5 +31,6 @@ "include": [] }, "appid": "wx3da23ea14ca66b65", - "editorSetting": {} -} + "editorSetting": {}, + "libVersion": "3.15.2" +} \ No newline at end of file diff --git a/project.private.config.json b/project.private.config.json index 220cf2f7..ba82fff3 100644 --- a/project.private.config.json +++ b/project.private.config.json @@ -2,13 +2,20 @@ "libVersion": "3.15.2", "projectname": "Genarrative", "setting": { - "urlCheck": true, + "urlCheck": false, "coverView": true, "lazyloadPlaceholderEnable": false, "skylineRenderEnable": false, "preloadBackgroundData": false, "autoAudits": false, "showShadowRootInWxmlPanel": true, - "compileHotReLoad": true + "compileHotReLoad": true, + "useApiHook": true, + "useStaticServer": false, + "useLanDebug": false, + "showES6CompileOption": false, + "checkInvalidKey": true, + "ignoreDevUnusedFiles": true, + "bigPackageSizeSupport": false } } \ No newline at end of file diff --git a/scripts/api-server-dev.mjs b/scripts/api-server-dev.mjs index 459c9f1f..49b76b6e 100644 --- a/scripts/api-server-dev.mjs +++ b/scripts/api-server-dev.mjs @@ -1,6 +1,11 @@ import { execFileSync, spawn } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { + createWriteStream, + existsSync, + mkdirSync, + readFileSync, +} from 'node:fs'; +import { dirname, isAbsolute, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const repoRoot = process.cwd(); @@ -67,7 +72,69 @@ export function mergeApiServerEnv(repoRootPath, baseEnv = process.env) { return mergedEnv; } -function stopExistingWindowsApiServer() { +export function formatApiServerLogTimestamp(date = new Date()) { + const pad = (value) => String(value).padStart(2, '0'); + + return [ + date.getFullYear(), + pad(date.getMonth() + 1), + pad(date.getDate()), + '-', + pad(date.getHours()), + pad(date.getMinutes()), + pad(date.getSeconds()), + ].join(''); +} + +export function resolveApiServerLogFile( + repoRootPath, + env = process.env, + now = new Date(), +) { + const explicitLogFile = String( + env.GENARRATIVE_API_SERVER_LOG_FILE ?? '', + ).trim(); + + if (explicitLogFile) { + return isAbsolute(explicitLogFile) + ? explicitLogFile + : resolve(repoRootPath, explicitLogFile); + } + + const logDir = + String(env.GENARRATIVE_API_SERVER_LOG_DIR ?? '').trim() || + 'logs/api-server'; + const resolvedLogDir = isAbsolute(logDir) + ? logDir + : resolve(repoRootPath, logDir); + + return resolve( + resolvedLogDir, + `api-server-${formatApiServerLogTimestamp(now)}.log`, + ); +} + +function createApiServerLogStream(logFilePath) { + mkdirSync(dirname(logFilePath), { recursive: true }); + const logStream = createWriteStream(logFilePath, { + flags: 'a', + encoding: 'utf8', + }); + logStream.on('error', (error) => { + console.error(`[api-server] 写入日志失败: ${error.message}`); + }); + return logStream; +} + +function writeLauncherLog(logStream, message, stream = process.stdout) { + const line = `${message}\n`; + stream.write(line); + if (!logStream.destroyed) { + logStream.write(line); + } +} + +function stopExistingWindowsApiServer(logStream) { if (process.platform !== 'win32') { return; } @@ -104,7 +171,7 @@ function stopExistingWindowsApiServer() { ).trim(); if (output) { - console.log(`[api-server] 已停止旧 api-server 进程: ${output}`); + writeLauncherLog(logStream, `[api-server] 已停止旧 api-server 进程: ${output}`); } } @@ -121,19 +188,55 @@ function main() { mergedEnv.GENARRATIVE_SPACETIME_TOKEN = mergedEnv.GENARRATIVE_SPACETIME_TOKEN || ''; + const logFilePath = resolveApiServerLogFile(repoRoot, mergedEnv); + const logStream = createApiServerLogStream(logFilePath); + mergedEnv.GENARRATIVE_API_SERVER_LOG_FILE = logFilePath; + + let didExit = false; + const exitAfterLogFlush = (code) => { + const finish = () => { + if (didExit) { + return; + } + didExit = true; + process.exit(code); + }; + + if (logStream.destroyed) { + finish(); + return; + } + + logStream.end(finish); + setTimeout(finish, 1000).unref(); + }; + + writeLauncherLog(logStream, `[api-server] 日志: ${logFilePath}`); + if (!mergedEnv.GENARRATIVE_SPACETIME_DATABASE) { - console.error('[api-server] 缺少 GENARRATIVE_SPACETIME_DATABASE。'); - process.exit(1); + writeLauncherLog( + logStream, + '[api-server] 缺少 GENARRATIVE_SPACETIME_DATABASE。', + process.stderr, + ); + exitAfterLogFlush(1); + return; } try { - stopExistingWindowsApiServer(); + stopExistingWindowsApiServer(logStream); } catch (error) { - console.error(`[api-server] 清理旧 api-server 进程失败: ${error.message}`); - process.exit(1); + writeLauncherLog( + logStream, + `[api-server] 清理旧 api-server 进程失败: ${error.message}`, + process.stderr, + ); + exitAfterLogFlush(1); + return; } - console.log( + writeLauncherLog( + logStream, `[api-server] SpacetimeDB ${mergedEnv.GENARRATIVE_SPACETIME_DATABASE} @ ${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`, ); @@ -143,22 +246,41 @@ function main() { { cwd: repoRoot, env: mergedEnv, - stdio: 'inherit', + stdio: ['inherit', 'pipe', 'pipe'], }, ); - child.on('error', (error) => { - console.error(`[api-server] 启动 cargo 失败: ${error.message}`); - process.exit(1); + child.stdout?.on('data', (chunk) => { + process.stdout.write(chunk); + logStream.write(chunk); }); - child.on('exit', (code, signal) => { + child.stderr?.on('data', (chunk) => { + process.stderr.write(chunk); + logStream.write(chunk); + }); + + child.on('error', (error) => { + writeLauncherLog( + logStream, + `[api-server] 启动 cargo 失败: ${error.message}`, + process.stderr, + ); + exitAfterLogFlush(1); + }); + + child.on('close', (code, signal) => { if (signal) { - console.error(`[api-server] api-server 被信号终止: ${signal}`); - process.exit(1); + writeLauncherLog( + logStream, + `[api-server] api-server 被信号终止: ${signal}`, + process.stderr, + ); + exitAfterLogFlush(1); + return; } - process.exit(code ?? 0); + exitAfterLogFlush(code ?? 0); }); } diff --git a/scripts/api-server-dev.test.ts b/scripts/api-server-dev.test.ts index 836fb1f5..267c0dfe 100644 --- a/scripts/api-server-dev.test.ts +++ b/scripts/api-server-dev.test.ts @@ -4,7 +4,11 @@ import { join } from 'node:path'; import { describe, expect, test } from 'vitest'; -import { mergeApiServerEnv } from './api-server-dev.mjs'; +import { + formatApiServerLogTimestamp, + mergeApiServerEnv, + resolveApiServerLogFile, +} from './api-server-dev.mjs'; type EnvMap = Record; @@ -92,3 +96,39 @@ describe('api-server-dev env merge', () => { ); }); }); + +describe('api-server-dev log file resolution', () => { + const fixedDate = new Date(2026, 4, 15, 6, 7, 8); + + test('默认写入 logs/api-server 的时间戳文件', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-log-')); + + try { + expect(formatApiServerLogTimestamp(fixedDate)).toBe('20260515-060708'); + expect(resolveApiServerLogFile(tempDir, {}, fixedDate)).toBe( + join(tempDir, 'logs/api-server/api-server-20260515-060708.log'), + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('GENARRATIVE_API_SERVER_LOG_FILE 优先于日志目录默认值', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-log-')); + + try { + expect( + resolveApiServerLogFile( + tempDir, + { + GENARRATIVE_API_SERVER_LOG_DIR: 'logs/ignored', + GENARRATIVE_API_SERVER_LOG_FILE: 'logs/custom/api.log', + }, + fixedDate, + ), + ).toBe(join(tempDir, 'logs/custom/api.log')); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/dev-rust-stack.sh b/scripts/dev-rust-stack.sh index 330868ed..6f7df857 100644 --- a/scripts/dev-rust-stack.sh +++ b/scripts/dev-rust-stack.sh @@ -653,11 +653,13 @@ wait_for_api_server() { local health_url="$1" local timeout_seconds="$2" local process_pid="${3:-}" + local log_file="${4:-${API_SERVER_LOG_FILE:-}}" local deadline=$((SECONDS + timeout_seconds)) while ((SECONDS < deadline)); do if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then echo "[dev:rust] api-server 进程在就绪前退出。" >&2 + print_api_server_log_tail "${log_file}" exit 1 fi @@ -679,9 +681,58 @@ request.on("error", () => process.exit(1)); done echo "[dev:rust] 等待 api-server 就绪超时: ${health_url}" >&2 + print_api_server_log_tail "${log_file}" exit 1 } +format_api_server_log_timestamp() { + date +%Y%m%d-%H%M%S +} + +normalize_api_server_log_path() { + local path_value="$1" + + if [[ "${path_value}" == *\\* ]]; then + path_value="${path_value//\\//}" + fi + + echo "${path_value}" +} + +resolve_api_server_log_file() { + local explicit_log_file="${GENARRATIVE_API_SERVER_LOG_FILE:-}" + local log_dir="${GENARRATIVE_API_SERVER_LOG_DIR:-${REPO_ROOT}/logs/api-server}" + + if [[ -n "${explicit_log_file//[[:space:]]/}" ]]; then + explicit_log_file="$(normalize_api_server_log_path "${explicit_log_file}")" + if [[ "${explicit_log_file}" = /* || "${explicit_log_file}" =~ ^[A-Za-z]:[\\/] ]]; then + echo "${explicit_log_file}" + return + fi + + echo "${REPO_ROOT}/${explicit_log_file}" + return + fi + + log_dir="$(normalize_api_server_log_path "${log_dir}")" + if [[ ! "${log_dir}" = /* && ! "${log_dir}" =~ ^[A-Za-z]:[\\/] ]]; then + log_dir="${REPO_ROOT}/${log_dir}" + fi + + echo "${log_dir}/api-server-dev-rust-$(format_api_server_log_timestamp).log" +} + +print_api_server_log_tail() { + local log_file="${1:-}" + + if [[ -z "${log_file}" || ! -f "${log_file}" ]]; then + return + fi + + echo "[dev:rust] api-server 最近日志: ${log_file}" >&2 + tail -n 80 "${log_file}" >&2 || true +} + generate_migration_bootstrap_secret() { node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));' @@ -990,22 +1041,26 @@ API_PORT="$(find_nearest_available_port "${API_HOST}" "${API_PORT}" "api-server" API_TARGET_HOST="$(resolve_client_host "${API_HOST}")" # `.env.local` 可以给单独 `dev:web` 配置代理目标,但完整栈的前端必须跟随本次 `--api-port`。 RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}" +API_SERVER_LOG_FILE="$(resolve_api_server_log_file)" +mkdir -p "$(dirname -- "${API_SERVER_LOG_FILE}")" echo "[dev:rust] api actual: ${RUST_SERVER_TARGET}" +echo "[dev:rust] api-server log: ${API_SERVER_LOG_FILE}" ( cd "${REPO_ROOT}" GENARRATIVE_API_HOST="${API_HOST}" \ GENARRATIVE_API_PORT="${API_PORT}" \ GENARRATIVE_API_LOG="${API_LOG}" \ + GENARRATIVE_API_SERVER_LOG_FILE="${API_SERVER_LOG_FILE}" \ GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER}" \ GENARRATIVE_SPACETIME_DATABASE="${DATABASE}" \ exec cargo run -p api-server --manifest-path "${MANIFEST_PATH}" -) & +) > >(tee -a "${API_SERVER_LOG_FILE}") 2>&1 & API_PID="$!" PIDS+=("${API_PID}") NAMES+=("api-server") echo "[dev:rust] 等待 api-server 就绪" -wait_for_api_server "${RUST_SERVER_TARGET}/healthz" "${API_SERVER_TIMEOUT_SECONDS}" "${API_PID}" +wait_for_api_server "${RUST_SERVER_TARGET}/healthz" "${API_SERVER_TIMEOUT_SECONDS}" "${API_PID}" "${API_SERVER_LOG_FILE}" echo "[dev:rust] 启动 vite" ( diff --git a/server-rs/crates/api-server/README.md b/server-rs/crates/api-server/README.md index 57e4d4b3..86b8cb6f 100644 --- a/server-rs/crates/api-server/README.md +++ b/server-rs/crates/api-server/README.md @@ -99,6 +99,7 @@ 1. 进程启动时通过 `shared-logging` 统一初始化 `tracing subscriber`。 2. 默认日志过滤器来自 `GENARRATIVE_API_LOG`,未提供时回落到 `info,tower_http=info`。 3. HTTP 访问日志统一通过 Axum 路由层的 `TraceLayer` 输出,后续 `request_id`、响应头与错误中间件继续在同一层扩展。 +4. 本地启动器 `npm run api-server` 和完整联调入口 `npm run dev` / `npm run dev:rust` 会在保留终端实时输出的同时,把同一份 `cargo` / `api-server` 输出持久化到 `logs/api-server/`。如需固定文件或目录,可设置 `GENARRATIVE_API_SERVER_LOG_FILE` 或 `GENARRATIVE_API_SERVER_LOG_DIR`。 当前 request context 约定: diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 657eb51d..f2898a05 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -1031,7 +1031,10 @@ test('profile recharge modal buys points through mock channel outside mini progr 'mock', ); }); - expect(await screen.findByText('已到账')).toBeTruthy(); + expect( + await screen.findByRole('dialog', { name: '支付成功' }), + ).toBeTruthy(); + expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy(); expect(onRechargeSuccess).toHaveBeenCalledTimes(1); }); @@ -1114,7 +1117,10 @@ test('profile recharge modal posts requestPayment params in mini program web-vie }); expect(navigateUrl).toContain('order-wechat-1'); expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay'); - expect(await screen.findByText('已到账')).toBeTruthy(); + expect( + await screen.findByRole('dialog', { name: '支付成功' }), + ).toBeTruthy(); + expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy(); expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith( 'order-wechat-1', ); @@ -1202,7 +1208,9 @@ test('profile recharge modal loads wechat js sdk before mini program payment bri window.location.hash = `wx_pay_result=${requestId}:success`; window.dispatchEvent(new HashChangeEvent('hashchange')); }); - expect(await screen.findByText('已到账')).toBeTruthy(); + expect( + await screen.findByRole('dialog', { name: '支付成功' }), + ).toBeTruthy(); }); test('profile recharge modal releases submitting state after cancelled wechat pay result', async () => { @@ -1283,7 +1291,10 @@ test('profile recharge modal releases submitting state after cancelled wechat pa window.dispatchEvent(new HashChangeEvent('hashchange')); }); - expect(await screen.findByText('支付已取消')).toBeTruthy(); + expect( + await screen.findByRole('dialog', { name: '支付已取消' }), + ).toBeTruthy(); + expect(screen.getByText('本次没有扣款,账户状态未发生变化。')).toBeTruthy(); await waitFor(() => { expect( within(screen.getByRole('button', { name: /60泥点/u })).getByText( diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 4ac8b0f2..fe63d38f 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -1,7 +1,9 @@ import { ArrowRight, + AlertCircle, BookOpen, Camera, + CheckCircle2, ChevronDown, ChevronRight, Clock3, @@ -26,6 +28,7 @@ import { Ticket, UserPlus, UserRound, + XCircle, } from 'lucide-react'; import { type ComponentType, @@ -222,6 +225,12 @@ type WechatPayResult = { orderId: string | null; status: WechatMiniProgramPaymentStatus; }; +type RechargePaymentResultKind = 'success' | 'pending' | 'cancel' | 'failed'; +type RechargePaymentResult = { + kind: RechargePaymentResultKind; + title: string; + message: string; +}; type DiscoverChannel = | 'recommend' | 'today' @@ -2501,7 +2510,6 @@ function ProfileRechargeModal({ center, isLoading, error, - success, submittingProductId, activeTab, onTabChange, @@ -2512,7 +2520,6 @@ function ProfileRechargeModal({ center: ProfileRechargeCenterResponse | null; isLoading: boolean; error: string | null; - success: string | null; submittingProductId: string | null; activeTab: RechargeTab; onTabChange: (tab: RechargeTab) => void; @@ -2582,11 +2589,6 @@ function ProfileRechargeModal({ ) : null} - {success ? ( -
- {success} -
- ) : null} {isLoading ? (
@@ -2619,6 +2621,62 @@ function ProfileRechargeModal({ ); } +function RechargePaymentResultModal({ + result, + onClose, +}: { + result: RechargePaymentResult; + onClose: () => void; +}) { + const Icon = + result.kind === 'success' + ? CheckCircle2 + : result.kind === 'cancel' + ? XCircle + : AlertCircle; + const iconClass = + result.kind === 'success' + ? 'text-[var(--platform-success-text)]' + : result.kind === 'cancel' + ? 'text-[var(--platform-text-soft)]' + : 'text-[var(--platform-button-danger-text)]'; + + return ( +
+
+
+
+
+
+ {result.title} +
+
+ {result.message} +
+ +
+
+
+ ); +} + function WalletLedgerModal({ ledger, fallbackBalance, @@ -3324,7 +3382,8 @@ export function RpgEntryHomeView({ useState(null); const [isLoadingRechargeCenter, setIsLoadingRechargeCenter] = useState(false); const [rechargeError, setRechargeError] = useState(null); - const [rechargeSuccess, setRechargeSuccess] = useState(null); + const [rechargePaymentResult, setRechargePaymentResult] = + useState(null); const [activeRechargeTab, setActiveRechargeTab] = useState('points'); const [submittingRechargeProductId, setSubmittingRechargeProductId] = @@ -3869,27 +3928,56 @@ export function RpgEntryHomeView({ } if (payResult.status === 'success') { - setRechargeSuccess('支付已提交'); + setRechargePaymentResult({ + kind: 'pending', + title: '支付已提交', + message: '正在确认到账状态,请稍后查看余额或会员状态。', + }); if (payResult.orderId) { void confirmWechatRpgProfileRechargeOrder(payResult.orderId) .then((response) => { setRechargeCenter(response.center); - setRechargeSuccess( - response.order.status === 'paid' ? '已到账' : '支付已提交', + setRechargePaymentResult( + response.order.status === 'paid' + ? { + kind: 'success', + title: '支付成功', + message: '已到账,账户状态已刷新。', + } + : { + kind: 'pending', + title: '支付已提交', + message: '正在等待微信支付确认,请稍后查看账户状态。', + }, ); setSubmittingRechargeProductId(null); pendingWechatRechargeOrderIdRef.current = null; }) - .catch(() => refreshRechargeState()); + .catch(() => { + setRechargePaymentResult({ + kind: 'pending', + title: '支付已提交', + message: '暂时没能确认到账状态,请稍后查看余额或会员状态。', + }); + refreshRechargeState(); + }); } else { refreshRechargeState(); } void onRechargeSuccess?.(); } else if (payResult.status === 'cancel') { - setRechargeSuccess('支付已取消'); + setRechargePaymentResult({ + kind: 'cancel', + title: '支付已取消', + message: '本次没有扣款,账户状态未发生变化。', + }); refreshRechargeState(); } else { - setRechargeError('微信支付未完成'); + setRechargePaymentResult({ + kind: 'failed', + title: '支付未完成', + message: '微信支付没有完成,本次不会入账。', + }); refreshRechargeState(); } @@ -3902,7 +3990,6 @@ export function RpgEntryHomeView({ } setIsRechargeOpen(true); - setRechargeSuccess(null); loadRechargeCenter(); }; const buyRechargeProduct = (product: ProfileRechargeProduct) => { @@ -3915,7 +4002,6 @@ export function RpgEntryHomeView({ : 'mock'; setSubmittingRechargeProductId(product.productId); setRechargeError(null); - setRechargeSuccess(null); void createRpgProfileRechargeOrder(product.productId, paymentChannel) .then(async (response) => { if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) { @@ -3928,7 +4014,11 @@ export function RpgEntryHomeView({ return; } else { setRechargeCenter(response.center); - setRechargeSuccess('已到账'); + setRechargePaymentResult({ + kind: 'success', + title: '支付成功', + message: '已到账,账户状态已刷新。', + }); pendingWechatRechargeOrderIdRef.current = null; setSubmittingRechargeProductId(null); } @@ -5712,7 +5802,6 @@ export function RpgEntryHomeView({ center={rechargeCenter} isLoading={isLoadingRechargeCenter} error={rechargeError} - success={rechargeSuccess} submittingProductId={submittingRechargeProductId} activeTab={activeRechargeTab} onTabChange={setActiveRechargeTab} @@ -5721,6 +5810,12 @@ export function RpgEntryHomeView({ onBuy={buyRechargeProduct} /> ) : null; + const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? ( + setRechargePaymentResult(null)} + /> + ) : null; if (!isDesktopLayout) { const isMobileRecommendTab = activeTab === 'home'; @@ -5804,6 +5899,7 @@ export function RpgEntryHomeView({ ) : null} {rewardCodeModal} {rechargeModal} + {rechargePaymentResultModal} {isTaskCenterOpen ? ( {rewardCodeModal} {rechargeModal} + {rechargePaymentResultModal} {isTaskCenterOpen ? (