Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes

This commit is contained in:
kdletters
2026-06-06 20:01:52 +08:00
425 changed files with 16451 additions and 6022 deletions

View File

@@ -5,10 +5,10 @@ set -euo pipefail
usage() {
cat <<'EOF'
用法:
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--health-url http://127.0.0.1:8082/healthz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101]
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101]
说明:
进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 healthz 检查。
进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。
若传入 --database会在重启前把 GENARRATIVE_SPACETIME_DATABASE 写入 api-server 环境文件,避免服务继续读取旧库。
失败时保留维护模式。
EOF
@@ -209,6 +209,7 @@ ensure_runtime_env_and_dirs() {
# 旧生产环境文件会被 server-provision 保留,不一定包含新增的运行态写入路径。
# 发布前只补缺省值,不覆盖线上已经定制过的目录或开关。
ensure_env_value "${api_env_file}" "GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS" "5000"
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_ENABLED" "true"
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_DIR" "/var/lib/genarrative/tracking-outbox"
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE" "500"
@@ -228,7 +229,7 @@ VERSION=""
RELEASE_ROOT="/opt/genarrative/releases"
CURRENT_LINK="/opt/genarrative/current"
SERVICE_NAME="genarrative-api.service"
HEALTH_URL="http://127.0.0.1:8082/healthz"
HEALTH_URL="http://127.0.0.1:8082/readyz"
API_ENV_FILE="/etc/genarrative/api-server.env"
DATABASE=""
SPACETIME_SERVER_URL=""
@@ -362,7 +363,7 @@ ln -sfn "${RELEASE_DIR}" "${CURRENT_LINK}"
echo "[production-api-deploy] 重启服务: ${SERVICE_NAME}"
systemctl restart "${SERVICE_NAME}"
echo "[production-api-deploy] 等待 healthz: ${HEALTH_URL}"
echo "[production-api-deploy] 等待 readiness: ${HEALTH_URL}"
for _ in {1..30}; do
if curl -fsS "${HEALTH_URL}" >/dev/null; then
"${SCRIPT_DIR}/maintenance-off.sh"
@@ -373,5 +374,5 @@ for _ in {1..30}; do
sleep 2
done
echo "[production-api-deploy] healthz 检查超时: ${HEALTH_URL}" >&2
echo "[production-api-deploy] readiness 检查超时: ${HEALTH_URL}" >&2
exit 1

View File

@@ -28,7 +28,7 @@ if [[ -z "${RUSTUP_HOME:-}" && -n "${ORIGINAL_HOME}" && -d "${ORIGINAL_HOME}/.ru
fi
# HOME 会在下面切到组件级缓存目录,因此这里先把真实用户的 Rust 工具链目录补进 PATH。
# Server-Provision 通过 cargo install 安装的 sccache 通常会落在 /root/.cargo/bin。
# Jenkins 构建节点预装的 Rust 工具和 sccache 通常会落在 /root/.cargo/bin。
for tool_dir in "${ORIGINAL_HOME}/.cargo/bin" /root/.cargo/bin /usr/local/cargo/bin; do
if [[ -d "${tool_dir}" && ":${PATH}:" != *":${tool_dir}:"* ]]; then
export PATH="${tool_dir}:${PATH}"

View File

@@ -4,6 +4,10 @@ set -euo pipefail
PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}"
SPACETIME_BIN_SOURCE="${SPACETIME_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/spacetime/spacetime}"
OTELCOL_BIN_SOURCE="${OTELCOL_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/otelcol-contrib}"
GENARRATIVE_OPENSSL_VERSION="${GENARRATIVE_OPENSSL_VERSION:-3.2.0}"
GENARRATIVE_OPENSSL_PREFIX="${GENARRATIVE_OPENSSL_PREFIX:-/opt/genarrative/openssl-3.2.0}"
GENARRATIVE_OPENSSL_SOURCE_URL="${GENARRATIVE_OPENSSL_SOURCE_URL:-https://github.com/openssl/openssl/releases/download/openssl-${GENARRATIVE_OPENSSL_VERSION}/openssl-${GENARRATIVE_OPENSSL_VERSION}.tar.gz}"
GENARRATIVE_OPENSSL_SOURCE_SHA256="${GENARRATIVE_OPENSSL_SOURCE_SHA256:-14c826f07c7e433706fb5c69fa9e25dab95684844b4c962a2cf1bf183eb4690e}"
require_non_root_relative_path() {
local label="$1"
@@ -27,6 +31,14 @@ require_path() {
fi
}
require_cmd() {
local name="$1"
if ! command -v "${name}" >/dev/null 2>&1; then
echo "[server-provision] 缺少命令: ${name}" >&2
exit 1
fi
}
normalize_server_aliases() {
printf "%s" "${SERVER_ALIASES:-}" | tr ',' ' ' | xargs
}
@@ -56,6 +68,18 @@ run_cmd() {
fi
}
require_root_for_real_provision() {
if [[ "${DRY_RUN}" == "true" ]]; then
return
fi
if [[ "$(id -u)" != "0" ]]; then
echo "[server-provision] 非 dry-run 会安装系统包、写入 systemd/Nginx 和创建系统用户,必须在 root agent 上执行。" >&2
echo "[server-provision] 当前用户: $(id -un) uid=$(id -u)。请确认 DEPLOY_TARGET=${DEPLOY_TARGET:-} 对应的目标服务器 agent 以 root 运行,或保持 DRY_RUN=true。" >&2
exit 1
fi
}
install_file() {
local source="$1"
local target="$2"
@@ -66,21 +90,6 @@ install_file() {
fi
}
install_build_dependencies() {
echo "[server-provision] 安装 Linux 构建依赖: clang, lld, pkg-config, OpenSSL headers"
if command -v apt-get >/dev/null 2>&1; then
run_cmd apt-get update
run_cmd apt-get install -y clang lld pkg-config libssl-dev ca-certificates
elif command -v dnf >/dev/null 2>&1; then
run_cmd dnf install -y clang lld pkgconf-pkg-config openssl-devel ca-certificates
elif command -v yum >/dev/null 2>&1; then
run_cmd yum install -y clang lld pkgconf-pkg-config openssl-devel ca-certificates
else
echo "[server-provision] 未找到 apt-get/dnf/yum无法自动安装 clang/lld。请手动安装后重跑构建。" >&2
exit 1
fi
}
install_nginx_brotli_modules() {
echo "[server-provision] 安装 Nginx Brotli 动态模块依赖"
if command -v apt-get >/dev/null 2>&1; then
@@ -90,39 +99,111 @@ install_nginx_brotli_modules() {
fi
}
install_sccache() {
for tool_dir in "${HOME:-}/.cargo/bin" /root/.cargo/bin /usr/local/cargo/bin; do
if [[ -d "${tool_dir}" && ":${PATH}:" != *":${tool_dir}:"* ]]; then
export PATH="${tool_dir}:${PATH}"
download_file() {
local url="$1"
local output="$2"
if command -v curl >/dev/null 2>&1; then
curl -fsSL --retry 3 --retry-delay 2 "${url}" -o "${output}"
elif command -v wget >/dev/null 2>&1; then
wget -O "${output}" "${url}"
else
echo "[server-provision] 需要 curl 或 wget 下载: ${url}" >&2
exit 1
fi
}
openssl_lib_dir_candidates() {
printf "%s\n" \
"${GENARRATIVE_OPENSSL_PREFIX}/lib64" \
"${GENARRATIVE_OPENSSL_PREFIX}/lib"
}
find_genarrative_openssl_lib_dir() {
local lib_dir
while IFS= read -r lib_dir; do
if [[ -f "${lib_dir}/libssl.so.3" && -f "${lib_dir}/libcrypto.so.3" ]]; then
printf "%s" "${lib_dir}"
return 0
fi
done
done < <(openssl_lib_dir_candidates)
return 1
}
if command -v sccache >/dev/null 2>&1; then
echo "[server-provision] sccache 已存在: $(command -v sccache)"
return
genarrative_openssl_has_required_symbol() {
local lib_dir
lib_dir="$(find_genarrative_openssl_lib_dir 2>/dev/null || true)"
if [[ -z "${lib_dir}" ]]; then
return 1
fi
grep -a -q "OPENSSL_${GENARRATIVE_OPENSSL_VERSION}" "${lib_dir}/libssl.so.3"
}
if [[ -x /root/.cargo/bin/sccache ]]; then
echo "[server-provision] sccache 已存在: /root/.cargo/bin/sccache"
return
verify_genarrative_openssl_install() {
local lib_dir
lib_dir="$(find_genarrative_openssl_lib_dir 2>/dev/null || true)"
if [[ -z "${lib_dir}" ]]; then
echo "[server-provision] OpenSSL ${GENARRATIVE_OPENSSL_VERSION} 安装后缺少 libssl.so.3/libcrypto.so.3: ${GENARRATIVE_OPENSSL_PREFIX}" >&2
exit 1
fi
if ! grep -a -q "OPENSSL_${GENARRATIVE_OPENSSL_VERSION}" "${lib_dir}/libssl.so.3"; then
echo "[server-provision] OpenSSL 动态库缺少 OPENSSL_${GENARRATIVE_OPENSSL_VERSION} 符号: ${lib_dir}/libssl.so.3" >&2
exit 1
fi
if ! env "LD_LIBRARY_PATH=${lib_dir}" "${GENARRATIVE_OPENSSL_PREFIX}/bin/openssl" version | grep -q "OpenSSL ${GENARRATIVE_OPENSSL_VERSION}"; then
echo "[server-provision] OpenSSL ${GENARRATIVE_OPENSSL_VERSION} 安装后命令验证失败: ${GENARRATIVE_OPENSSL_PREFIX}/bin/openssl" >&2
exit 1
fi
echo "[server-provision] OpenSSL ${GENARRATIVE_OPENSSL_VERSION} 已就绪: ${lib_dir}"
}
echo "[server-provision] 未找到 sccache准备通过 cargo install sccache 安装。"
install_genarrative_openssl_runtime() {
local tmp_dir archive source_dir jobs lib_dir
echo "[server-provision] 检查 api-server/libcurl 运行时 OpenSSL ${GENARRATIVE_OPENSSL_VERSION}"
if [[ "${DRY_RUN}" == "true" ]]; then
echo "+ cargo install sccache --locked"
echo "+ install OpenSSL ${GENARRATIVE_OPENSSL_VERSION} into ${GENARRATIVE_OPENSSL_PREFIX}"
echo "+ verify OPENSSL_${GENARRATIVE_OPENSSL_VERSION} symbol for api-server/libcurl"
return
fi
if ! command -v cargo >/dev/null 2>&1; then
echo "[server-provision] 未找到 cargo无法自动安装 sccache。请先安装 Rust 工具链后重跑 Server-Provision。" >&2
exit 1
if genarrative_openssl_has_required_symbol; then
verify_genarrative_openssl_install
return
fi
cargo install sccache --locked
if ! command -v sccache >/dev/null 2>&1 && [[ ! -x /root/.cargo/bin/sccache ]]; then
echo "[server-provision] sccache 安装后仍不可用,请检查 cargo bin 目录是否在 PATH 中。" >&2
if command -v apt-get >/dev/null 2>&1; then
run_cmd apt-get install -y build-essential ca-certificates curl perl tar
else
echo "[server-provision] 当前系统未使用 apt无法自动构建 OpenSSL ${GENARRATIVE_OPENSSL_VERSION};请手动安装到 ${GENARRATIVE_OPENSSL_PREFIX}" >&2
exit 1
fi
require_cmd sha256sum
require_cmd tar
tmp_dir="$(mktemp -d)"
archive="${tmp_dir}/openssl-${GENARRATIVE_OPENSSL_VERSION}.tar.gz"
echo "[server-provision] 下载 OpenSSL ${GENARRATIVE_OPENSSL_VERSION}: ${GENARRATIVE_OPENSSL_SOURCE_URL}"
download_file "${GENARRATIVE_OPENSSL_SOURCE_URL}" "${archive}"
printf "%s %s\n" "${GENARRATIVE_OPENSSL_SOURCE_SHA256}" "${archive}" | sha256sum -c -
tar -xzf "${archive}" -C "${tmp_dir}"
source_dir="${tmp_dir}/openssl-${GENARRATIVE_OPENSSL_VERSION}"
jobs="$(nproc 2>/dev/null || echo 2)"
(
cd "${source_dir}"
./config --prefix="${GENARRATIVE_OPENSSL_PREFIX}" --openssldir="${GENARRATIVE_OPENSSL_PREFIX}/ssl" shared
make -j "${jobs}"
make install_sw
)
rm -rf "${tmp_dir}"
lib_dir="$(find_genarrative_openssl_lib_dir 2>/dev/null || true)"
if [[ -n "${lib_dir}" ]]; then
chmod 0755 "${GENARRATIVE_OPENSSL_PREFIX}" "${lib_dir}" || true
chmod 0644 "${lib_dir}/libssl.so.3" "${lib_dir}/libcrypto.so.3" || true
fi
verify_genarrative_openssl_install
}
sync_otelcol_install() {
@@ -142,7 +223,7 @@ sync_otelcol_install() {
if [[ ! -x "${resolved_source}" ]]; then
echo "[server-provision] otelcol-contrib 不存在或不可执行: ${source_bin}" >&2
echo "[server-provision] 请先在构建机准备好 otelcol-contrib ${version},再通过 provision-tools 上传到目标机。" >&2
echo "[server-provision] 请确认 Prepare Provision Tools 已在目标 agent 生成 otelcol-contrib ${version}: ${source_bin}" >&2
exit 1
fi
@@ -671,9 +752,8 @@ require_non_root_relative_path "PROVISION_TOOLS_DIR" "${PROVISION_TOOLS_DIR}"
echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_config_mode=${NGINX_CONFIG_MODE}, source_commit=$(cat .jenkins-source-commit)"
run_cmd id
install_build_dependencies
require_root_for_real_provision
install_nginx_brotli_modules
install_sccache
run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox /var/lib/genarrative/database-backups
if ! id spacetimedb >/dev/null 2>&1; then
@@ -690,6 +770,7 @@ fi
run_cmd chown -R spacetimedb:spacetimedb "${SPACETIME_ROOT}"
run_cmd chown -R genarrative:genarrative /opt/genarrative /var/lib/genarrative /srv/genarrative
install_genarrative_openssl_runtime
if [[ ! -x "${SPACETIME_BIN_SOURCE}" ]]; then
echo "[server-provision] spacetime CLI 不存在或不可执行: ${SPACETIME_BIN_SOURCE}" >&2

View File

@@ -17,18 +17,24 @@ type MiniProgramPage = {
data: Record<string, unknown>;
setData: (patch: Record<string, unknown>) => void;
onLoad: (query?: Record<string, string>) => Promise<void>;
onShareAppMessage: () => Record<string, unknown>;
onShareTimeline: () => Record<string, unknown>;
onShow: () => void;
consumePayResult: () => void;
};
function createWxMock() {
return {
getAccountInfoSync: vi.fn(() => ({
miniProgram: { envVersion: 'release' },
})),
getStorageSync: vi.fn(() => ''),
getSystemInfoSync: vi.fn(() => ({ platform: 'ios' })),
login: vi.fn(),
navigateBack: vi.fn(),
removeStorageSync: vi.fn(),
request: vi.fn(),
showShareMenu: vi.fn(),
setStorageSync: vi.fn(),
};
}
@@ -54,6 +60,8 @@ function loadWebViewPage(
if (requestPath === '../../config') {
return {
API_BASE_URL: 'https://www.genarrative.world/',
DEV_API_BASE_URL: 'https://dev.genarrative.world/',
DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world/',
MINI_PROGRAM_APP_ID: 'wx-test-app',
MINI_PROGRAM_ENV: 'release',
WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world/',
@@ -91,7 +99,7 @@ describe('mini-program web-view auth page', () => {
vi.clearAllMocks();
});
test('默认进入时刷新微信小程序登录态后打开 web-view', async () => {
test('默认进入时不预登录,直接打开未登录 web-view', async () => {
const wxMock = createWxMock();
wxMock.login.mockImplementation(({ success }) => {
success({ code: 'wx-login-code' });
@@ -109,19 +117,58 @@ describe('mini-program web-view auth page', () => {
await page.onLoad({});
expect(wxMock.login).toHaveBeenCalledTimes(1);
expect(wxMock.request).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://www.genarrative.world/api/auth/wechat/miniprogram-login',
method: 'POST',
data: { code: 'wx-login-code' },
}),
);
expect(wxMock.login).not.toHaveBeenCalled();
expect(wxMock.request).not.toHaveBeenCalled();
expect(page.data.loading).toBe(false);
expect(page.data.phoneBindingRequired).toBe(false);
expect(page.data.webViewUrl).toBe(
'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program#auth_provider=wechat&auth_token=jwt-active-wechat&auth_binding_status=active',
'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program',
);
expect(wxMock.showShareMenu).toHaveBeenCalledWith({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline'],
});
});
test('默认进入时即便微信新身份待绑手机号,也不弹出绑定手机号页', async () => {
const wxMock = createWxMock();
wxMock.login.mockImplementation(({ success }) => {
success({ code: 'wx-login-code' });
});
wxMock.request.mockImplementation(({ success }) => {
success({
statusCode: 200,
data: {
token: 'jwt-pending-wechat',
bindingStatus: 'pending_bind_phone',
},
});
});
const page = loadWebViewPage(wxMock);
await page.onLoad({});
expect(wxMock.login).not.toHaveBeenCalled();
expect(wxMock.request).not.toHaveBeenCalled();
expect(page.data.loading).toBe(false);
expect(page.data.phoneBindingRequired).toBe(false);
expect(page.data.webViewUrl).toBe(
'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program',
);
});
test('web-view 页面分享好友和朋友圈都回到小程序 web-view 入口', () => {
const wxMock = createWxMock();
const page = loadWebViewPage(wxMock);
expect(page.onShareAppMessage()).toEqual({
title: '陶泥儿',
path: '/pages/web-view/index',
});
expect(page.onShareTimeline()).toEqual({
title: '陶泥儿',
query: '',
});
});
test('默认匿名进入 web-view 仍不依赖 API_BASE_URL 配置', async () => {
@@ -140,6 +187,51 @@ describe('mini-program web-view auth page', () => {
);
});
test('体验版自动切到 dev 子域名并透传 trial 环境', async () => {
const wxMock = createWxMock();
wxMock.getAccountInfoSync.mockReturnValue({
miniProgram: { envVersion: 'trial' },
});
const page = loadWebViewPage(wxMock);
await page.onLoad({});
expect(page.data.webViewUrl).toBe(
'https://dev.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program&miniProgramEnv=trial',
);
});
test('开发版自动切到 dev 子域名并把 develop 规整为 dev', async () => {
const wxMock = createWxMock();
wxMock.getAccountInfoSync.mockReturnValue({
miniProgram: { envVersion: 'develop' },
});
wxMock.login.mockImplementation(({ success }) => {
success({ code: 'wx-login-code' });
});
wxMock.request.mockImplementation(({ success }) => {
success({
statusCode: 200,
data: {
token: 'jwt-pending-wechat',
bindingStatus: 'pending_bind_phone',
},
});
});
const page = loadWebViewPage(wxMock);
await page.onLoad({ authAction: 'login', returnTo: 'previous' });
expect(wxMock.request).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://dev.genarrative.world/api/auth/wechat/miniprogram-login',
header: expect.objectContaining({
'x-mini-program-env': 'dev',
}),
}),
);
});
test('onShow 二次检查支付结果并写回 web-view hash', () => {
const wxMock = createWxMock();
wxMock.getStorageSync.mockImplementation((key) =>