Persist api-server logs and refresh recharge balance
This commit is contained in:
@@ -69,6 +69,8 @@ npm run dev:web
|
||||
npm run api-server
|
||||
```
|
||||
|
||||
该命令会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-<timestamp>.log`。完整联调入口 `npm run dev` / `npm run dev:rust` 启动的 Rust `api-server` 也会写入 `logs/api-server/api-server-dev-rust-<timestamp>.log`。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`。
|
||||
|
||||
查看本地 Rust/SpacetimeDB 日志:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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=<requestId>: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. 验收
|
||||
|
||||
|
||||
@@ -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:<spacetime-port>` 输出中的 `Server is online:`;如果 Windows 下 SpacetimeDB CLI `2.1.0` 对已经监听的 standalone 仍打印 `502 Bad Gateway`,脚本会兜底请求 `http://127.0.0.1:<spacetime-port>/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:<api-port>/healthz` 返回 HTTP 响应后再启动 Vite,避免前端初始化请求早于 Rust `api-server` 监听完成并在终端刷出 `ECONNREFUSED 127.0.0.1:<api-port>`。
|
||||
10. 注入 `RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。
|
||||
11. 任一子进程退出时,脚本回收其余子进程。
|
||||
@@ -119,6 +119,12 @@ npm run dev:rust:logs -- --follow
|
||||
3. 默认输出到 `logs/spacetime/<database>-<timestamp>.log`,并通过 `tee` 同步显示在终端。
|
||||
4. `--follow` 仅用于本地追踪,会持续追加到同一个输出文件;停止时用 `Ctrl+C`。
|
||||
|
||||
api-server 本地持久化日志:
|
||||
|
||||
1. `npm run api-server` 默认写入 `logs/api-server/api-server-<timestamp>.log`,同时继续把同一份输出显示在当前终端。
|
||||
2. `npm run dev` / `npm run dev:rust` 中由脚本启动的 Rust `api-server` 默认写入 `logs/api-server/api-server-dev-rust-<timestamp>.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/<database>/subscribe` 是否指向了未发布的库。
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string>;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
(
|
||||
|
||||
@@ -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 约定:
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{success ? (
|
||||
<div className="platform-profile-success mt-4 rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||
{success}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
@@ -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 (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[90] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="recharge-payment-result-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
|
||||
>
|
||||
<div className="px-5 pb-5 pt-6 text-center">
|
||||
<div
|
||||
className={`mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-white/10 ${iconClass}`}
|
||||
>
|
||||
<Icon className="h-8 w-8" aria-hidden="true" />
|
||||
</div>
|
||||
<div
|
||||
id="recharge-payment-result-title"
|
||||
className="mt-4 text-xl font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{result.title}
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-semibold leading-6 text-[var(--platform-text-soft)]">
|
||||
{result.message}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-primary-button mt-5 w-full rounded-2xl px-4 py-3 text-sm font-black"
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WalletLedgerModal({
|
||||
ledger,
|
||||
fallbackBalance,
|
||||
@@ -3324,7 +3382,8 @@ export function RpgEntryHomeView({
|
||||
useState<ProfileRechargeCenterResponse | null>(null);
|
||||
const [isLoadingRechargeCenter, setIsLoadingRechargeCenter] = useState(false);
|
||||
const [rechargeError, setRechargeError] = useState<string | null>(null);
|
||||
const [rechargeSuccess, setRechargeSuccess] = useState<string | null>(null);
|
||||
const [rechargePaymentResult, setRechargePaymentResult] =
|
||||
useState<RechargePaymentResult | null>(null);
|
||||
const [activeRechargeTab, setActiveRechargeTab] =
|
||||
useState<RechargeTab>('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 ? (
|
||||
<RechargePaymentResultModal
|
||||
result={rechargePaymentResult}
|
||||
onClose={() => setRechargePaymentResult(null)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (!isDesktopLayout) {
|
||||
const isMobileRecommendTab = activeTab === 'home';
|
||||
@@ -5804,6 +5899,7 @@ export function RpgEntryHomeView({
|
||||
) : null}
|
||||
{rewardCodeModal}
|
||||
{rechargeModal}
|
||||
{rechargePaymentResultModal}
|
||||
{isTaskCenterOpen ? (
|
||||
<ProfileTaskCenterModal
|
||||
center={taskCenter}
|
||||
@@ -5935,6 +6031,7 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
{rewardCodeModal}
|
||||
{rechargeModal}
|
||||
{rechargePaymentResultModal}
|
||||
{isTaskCenterOpen ? (
|
||||
<ProfileTaskCenterModal
|
||||
center={taskCenter}
|
||||
|
||||
Reference in New Issue
Block a user