Compare commits
1 Commits
fix/dataso
...
fix/route-
| Author | SHA1 | Date | |
|---|---|---|---|
| ce9bde35eb |
@@ -75,10 +75,7 @@ jobs:
|
||||
run: |
|
||||
cd ${{ vars.REMOTE_DIR || '/opt/gongxue' }}
|
||||
sleep 3
|
||||
# 从 .env 读 PORT(默认 3000),避免硬编码端口与后端不一致
|
||||
PORT="$(grep '^PORT=' .env 2>/dev/null | cut -d= -f2- | tr -d '"' || true)"
|
||||
PORT="${PORT:-3000}"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/api/dashboard/stats" || true)"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/api/dashboard/stats || true)"
|
||||
echo "后端 HTTP 状态: ${code}(401=正常,接口需登录)"
|
||||
if [ "${code}" != "401" ] && [ "${code}" != "200" ]; then
|
||||
echo "健康检查失败:后端未按预期响应" >&2
|
||||
|
||||
105
apps/admin/src/components/DefaultRoute.integration.test.tsx
Normal file
105
apps/admin/src/components/DefaultRoute.integration.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import DefaultRoute from './DefaultRoute';
|
||||
import { RouteKeeper } from './RouteKeeper';
|
||||
import { usePermissionStore } from '../store/permission/permissionStore';
|
||||
import { useUserStore } from '../store/user/userStore';
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
beforeAll(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// 清掉可能由其他测试文件遗留的持久化数据,保证会话状态可控
|
||||
Object.keys(localStorage).forEach((key) => localStorage.removeItem(key));
|
||||
useUserStore.setState({ token: null, user: null });
|
||||
usePermissionStore.setState({ permissions: [], status: 'unknown' });
|
||||
useUserStore.getState().setSession('test-token', {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
roles: ['超级管理员'],
|
||||
});
|
||||
usePermissionStore.getState().writePermissions(['dashboard:view', 'student:view']);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) await act(async () => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
function PageDashboard() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="page-dashboard">数据面板</div>
|
||||
<button data-testid="go-students" onClick={() => navigate('/students')}>
|
||||
去学生管理
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageStudents() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="page-students">学生管理</div>
|
||||
<button data-testid="go-home" onClick={() => navigate('/')}>
|
||||
回首页
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
return (
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<Routes>
|
||||
<Route path="/" element={<RouteKeeper />}>
|
||||
<Route index element={<DefaultRoute />} />
|
||||
<Route path="dashboard" element={<PageDashboard />} />
|
||||
<Route path="students" element={<PageStudents />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
async function renderHarness() {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<Harness />);
|
||||
});
|
||||
}
|
||||
|
||||
describe('DefaultRoute keep-alive regression', () => {
|
||||
it('redirects to landing page once and does not hijack later navigations', async () => {
|
||||
await renderHarness();
|
||||
|
||||
// 首次进入 '/' 应跳转到落地页 /dashboard,且只跳一次
|
||||
expect(document.querySelector('[data-testid="page-dashboard"]')).not.toBeNull();
|
||||
|
||||
// 再导航到 /students,不应被保活的首页节点拉回 /dashboard
|
||||
await act(async () => {
|
||||
(document.querySelector('[data-testid="go-students"]') as HTMLButtonElement).click();
|
||||
});
|
||||
expect(document.querySelector('[data-testid="page-students"]')).not.toBeNull();
|
||||
|
||||
// 回到 '/' 时仍应再次跳转到落地页
|
||||
await act(async () => {
|
||||
(document.querySelector('[data-testid="go-home"]') as HTMLButtonElement).click();
|
||||
});
|
||||
expect(document.querySelector('[data-testid="page-dashboard"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { Result, Spin } from 'antd';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
import { findRoleAwareLandingPath } from '../auth/menu-policy';
|
||||
@@ -8,14 +8,31 @@ import { useUserStore } from '../store/user/userStore';
|
||||
const DefaultRoute: React.FC = () => {
|
||||
const { permissions, permissionsReady } = usePermission();
|
||||
const roles = useUserStore((state) => state.user?.roles ?? []);
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const firstPath = permissionsReady ? findRoleAwareLandingPath(roles, permissions) : null;
|
||||
|
||||
// 用 effect 导航替代声明式 <Navigate>:RouteKeeper 会把首页(index)节点保活缓存,
|
||||
// 声明式 <Navigate> 在缓存节点随路由变化重渲染时会反复触发,导致首次进入系统后
|
||||
// 点击任何按钮都被拉回 dashboard,必须刷新页面才能恢复。这里仅在确实处于首页
|
||||
// 且已计算出落点时跳转;navigate 通过 ref 持有,避免其每次渲染变化导致 effect 空转。
|
||||
const navigateRef = useRef(navigate);
|
||||
navigateRef.current = navigate;
|
||||
useEffect(() => {
|
||||
if (pathname === '/' && firstPath) {
|
||||
navigateRef.current(firstPath, { replace: true });
|
||||
}
|
||||
}, [pathname, firstPath]);
|
||||
|
||||
if (!permissionsReady) {
|
||||
return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||
}
|
||||
const firstPath = findRoleAwareLandingPath(roles, permissions);
|
||||
if (firstPath) return <Navigate to={firstPath} replace />;
|
||||
return (
|
||||
<Result status="403" title="暂无可访问功能" subTitle="请联系管理员为当前账号分配功能权限" />
|
||||
);
|
||||
if (!firstPath) {
|
||||
return (
|
||||
<Result status="403" title="暂无可访问功能" subTitle="请联系管理员为当前账号分配功能权限" />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default DefaultRoute;
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { config } from 'dotenv';
|
||||
import { basename, join, resolve } from 'path';
|
||||
import { join } from 'path';
|
||||
|
||||
// TypeORM CLI 默认在 apps/server/ 下运行,但生产环境的 .env 通常放在仓库根目录。
|
||||
// 这里先加载仓库根目录的 .env(默认),再加载当前目录(apps/server/.env)作为补充/覆盖,
|
||||
// 这样无论 .env 放哪一份,迁移 CLI 都能读到数据库配置。
|
||||
const root = resolve(process.cwd());
|
||||
const repoRoot =
|
||||
basename(root) === 'server' && basename(join(root, '..')) === 'apps'
|
||||
? resolve(root, '../..')
|
||||
: root;
|
||||
|
||||
// 先根目录,后当前目录(dotenv 默认不覆盖已存在变量,所以根目录优先级更高)
|
||||
config({ path: join(repoRoot, '.env') });
|
||||
// TypeORM CLI runs from apps/server/
|
||||
const root = process.cwd();
|
||||
config({ path: join(root, '.env') });
|
||||
|
||||
export default new DataSource({
|
||||
|
||||
108
deploy.sh
108
deploy.sh
@@ -1,84 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# 恭学教育 — PM2 部署脚本
|
||||
# 用法:
|
||||
# ./deploy.sh # 本地直跑(默认,适合 runner/代码就在生产服务器上,不需要 SSH)
|
||||
# ./deploy.sh local # 同上
|
||||
# ./deploy.sh <ssh_host> # 远程部署(构建后 rsync 到远端,需要 SSH 免密/key)
|
||||
# 用法: ./deploy.sh [ssh_host]
|
||||
# 前提: 服务器 MySQL 已在运行,PM2 已安装
|
||||
set -euo pipefail
|
||||
|
||||
MODE="${1:-local}"
|
||||
SSH_HOST="${1:-tencent}"
|
||||
|
||||
# 调用锁:mkdir 原子创建,已存在即代表已有部署在跑,直接退出
|
||||
LOCK_DIR="/tmp/gongxue-deploy.lock"
|
||||
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
|
||||
echo "检测到 $LOCK_DIR,已有部署在运行。若确认没有其他部署,请手动删除该锁目录。" >&2
|
||||
exit 1
|
||||
fi
|
||||
release_deploy_lock() {
|
||||
rmdir "$LOCK_DIR" 2>/dev/null || true
|
||||
}
|
||||
trap release_deploy_lock EXIT
|
||||
|
||||
echo "=== 1/4 本地构建后端 ==="
|
||||
npm run build -w @gongxue/server
|
||||
|
||||
echo "=== 2/4 本地构建前端 ==="
|
||||
npm run build -w @gongxue/admin
|
||||
|
||||
# ── 健康检查(本地/远程共用逻辑,通过 pm2 jlist 按 name 判断 online 且无 unstable_restarts)──
|
||||
# online ≠ healthy:若后续后端提供健康端点,应优先 curl 探测。
|
||||
run_health_check() {
|
||||
set -euo pipefail
|
||||
local healthy=0
|
||||
local i
|
||||
for i in $(seq 1 15); do
|
||||
if pm2 jlist 2>/dev/null | node -e '
|
||||
let data = "";
|
||||
process.stdin.on("data", (c) => (data += c));
|
||||
process.stdin.on("end", () => {
|
||||
const apps = JSON.parse(data || "[]");
|
||||
const app = apps.find((a) => a && a.name === "gongxue-backend");
|
||||
const env = (app || {}).pm2_env || {};
|
||||
if (env.status === "online" && Number(env.unstable_restarts || 0) === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
'; then
|
||||
healthy=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "${healthy}" -ne 1 ]; then
|
||||
echo "错误:后端进程未就绪(gongxue-backend 非 online 或存在不稳定重启)" >&2
|
||||
pm2 status >&2
|
||||
return 1
|
||||
fi
|
||||
echo "健康检查通过:gongxue-backend 在线"
|
||||
}
|
||||
|
||||
if [ "${MODE}" = "local" ]; then
|
||||
echo "=== 3/4 安装依赖 → 迁移 → PM2 重载(本机) ==="
|
||||
npm ci
|
||||
echo '执行数据库迁移...'
|
||||
npm run migration:run -w @gongxue/server
|
||||
echo 'PM2 重载...'
|
||||
pm2 startOrReload ecosystem.config.cjs --update-env
|
||||
pm2 save
|
||||
pm2 status
|
||||
|
||||
echo "=== 4/4 健康检查 ==="
|
||||
run_health_check
|
||||
echo ""
|
||||
echo "部署完成!后端: http://127.0.0.1:$(grep '^PORT=' .env 2>/dev/null | cut -d= -f2- | tr -d '"' || echo 3000)/api"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 远程模式(保留,用于部署到其他主机)──
|
||||
SSH_HOST="${MODE}"
|
||||
# 校验 SSH_HOST:非空、不以 - 开头、仅允许安全字符(防注入)
|
||||
# 校验 SSH_HOST:非空、不以 - 开头(防被 ssh/rsync 当作选项)、仅允许安全字符(防命令注入)
|
||||
if [[ -z "${SSH_HOST}" ]]; then
|
||||
echo "错误:SSH_HOST 不能为空" >&2
|
||||
exit 1
|
||||
@@ -93,6 +21,23 @@ if [[ ! "${SSH_HOST}" =~ ^[A-Za-z0-9._:@%+=,-]+$ ]]; then
|
||||
fi
|
||||
REMOTE_DIR="/opt/gongxue"
|
||||
|
||||
# 调用锁:mkdir 原子创建,已存在即代表已有部署在跑,直接退出
|
||||
LOCK_DIR="/tmp/gongxue-deploy.lock"
|
||||
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
|
||||
echo "检测到 $LOCK_DIR,已有部署在运行。若确认没有其他部署,请手动删除该锁目录。" >&2
|
||||
exit 1
|
||||
fi
|
||||
release_deploy_lock() {
|
||||
rmdir "$LOCK_DIR" 2>/dev/null || true
|
||||
}
|
||||
trap release_deploy_lock EXIT
|
||||
|
||||
echo "=== 1/5 本地构建后端 ==="
|
||||
npm run build -w @gongxue/server
|
||||
|
||||
echo "=== 2/5 本地构建前端 ==="
|
||||
npm run build -w @gongxue/admin
|
||||
|
||||
echo "=== 3/5 同步到 ${SSH_HOST} ==="
|
||||
# --delete 保留,但必须排除服务器端独有文件(.env、数据、上传目录),防止被清掉
|
||||
rsync -avz --delete \
|
||||
@@ -121,11 +66,18 @@ ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_HOST}" "
|
||||
echo 'PM2 重载...'
|
||||
pm2 startOrReload ecosystem.config.cjs --update-env
|
||||
pm2 save
|
||||
echo '=== PM2 状态 ==='
|
||||
pm2 status
|
||||
"
|
||||
|
||||
echo "=== 5/5 健康检查 ==="
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_HOST}" bash -s <<'REMOTE_HEALTH'
|
||||
# 后端未提供 /api/health 等专用健康端点,改用 pm2 jlist(JSON)按 name 查找 gongxue-backend:
|
||||
# 判断 status === 'online' 且 unstable_restarts === 0。
|
||||
# 说明:
|
||||
# - restart_time 是累计重启次数(正常滚动/长期运行也会累计),不适合做健康阈值,改用 unstable_restarts。
|
||||
# - online ≠ healthy:online 只代表 PM2 认为进程存活;若后续后端提供健康端点,应优先 curl 探测。
|
||||
# 带重试以覆盖 PM2 reload 的 listen_timeout(8s)窗口;应用不在线则 exit 1,部署失败。
|
||||
if ! ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_HOST}" bash -s <<'REMOTE_HEALTH'
|
||||
set -euo pipefail
|
||||
HEALTHY=0
|
||||
for _i in $(seq 1 15); do
|
||||
@@ -154,6 +106,10 @@ if [ "${HEALTHY}" -ne 1 ]; then
|
||||
fi
|
||||
echo "健康检查通过:gongxue-backend 在线"
|
||||
REMOTE_HEALTH
|
||||
then
|
||||
echo "错误:健康检查失败,部署中止" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "部署完成!"
|
||||
|
||||
Reference in New Issue
Block a user