feat(server): 单域托管 client/dist 与 SPA 回退(E12)
- 新增 SERVE_CLIENT_DIST/CLIENT_DIST_DIR 配置 - 开启后注册 @fastify/static 并对非 /api GET(html) 回退 index.html - docker-compose.yml 与 README 补充用法
这个提交包含在:
@@ -25,6 +25,8 @@ services:
|
|||||||
JWT_SECRET: "change_me"
|
JWT_SECRET: "change_me"
|
||||||
APP_TIMEZONE: "Asia/Shanghai"
|
APP_TIMEZONE: "Asia/Shanghai"
|
||||||
STORAGE_DIR: "generated/storage"
|
STORAGE_DIR: "generated/storage"
|
||||||
|
SERVE_CLIENT_DIST: "false"
|
||||||
|
CLIENT_DIST_DIR: "../client/dist"
|
||||||
SIGNED_URL_TTL_SECONDS: "600"
|
SIGNED_URL_TTL_SECONDS: "600"
|
||||||
MAX_UPLOAD_SIZE_BYTES: "10485760"
|
MAX_UPLOAD_SIZE_BYTES: "10485760"
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ E8,默认账号与失败体验收敛,补dev seed默认账号文档并让前端
|
|||||||
E9,DB索引补强(试运行性能),为列表分页与筛选补组合索引以降低试运行慢查询风险,迁移可重复执行且回归全绿,cd server && npm run prisma:migrate:deploy && ./scripts/regression-all.sh,manual,DONE,TODO,TODO,server/prisma/schema.prisma | server/prisma/migrations/*,"[""E1""]",新增组合索引仅增不破回滚成本低
|
E9,DB索引补强(试运行性能),为列表分页与筛选补组合索引以降低试运行慢查询风险,迁移可重复执行且回归全绿,cd server && npm run prisma:migrate:deploy && ./scripts/regression-all.sh,manual,DONE,TODO,TODO,server/prisma/schema.prisma | server/prisma/migrations/*,"[""E1""]",新增组合索引仅增不破回滚成本低
|
||||||
E10,后端安全基线(上线级),增加安全头与关键接口限流并完善上传校验与日志脱敏约定,限流触发行为正确安全头存在上传非法类型被拒绝且不泄露token,"manual: curl压测login并验证429与headers并做一次上传非法类型校验",manual,DONE,TODO,TODO,server/src/app.js | server/src/config.js | server/src/routes/auth.js | server/src/routes/invoicePool.js | server/README.md | server/package.json,"[""E1""]",优先使用@fastify/helmet与@fastify/rate-limit并写回滚说明
|
E10,后端安全基线(上线级),增加安全头与关键接口限流并完善上传校验与日志脱敏约定,限流触发行为正确安全头存在上传非法类型被拒绝且不泄露token,"manual: curl压测login并验证429与headers并做一次上传非法类型校验",manual,DONE,TODO,TODO,server/src/app.js | server/src/config.js | server/src/routes/auth.js | server/src/routes/invoicePool.js | server/README.md | server/package.json,"[""E1""]",优先使用@fastify/helmet与@fastify/rate-limit并写回滚说明
|
||||||
E11,可观测性基线(ready与作业统计),新增/ready进行DB探活并为OCR与委派回收作业输出结构化统计,ready可反映db状态且作业日志输出claimed updated audited等计数便于排障,"manual: 启动服务curl /ready并触发一次job观察日志字段",manual,DONE,TODO,TODO,server/src/app.js | server/src/jobs/delegationRevokeJob.js | server/src/jobs/ocrJob.js | server/README.md,"[""E10""]",不引入外部监控先提供最小可观测字段
|
E11,可观测性基线(ready与作业统计),新增/ready进行DB探活并为OCR与委派回收作业输出结构化统计,ready可反映db状态且作业日志输出claimed updated audited等计数便于排障,"manual: 启动服务curl /ready并触发一次job观察日志字段",manual,DONE,TODO,TODO,server/src/app.js | server/src/jobs/delegationRevokeJob.js | server/src/jobs/ocrJob.js | server/README.md,"[""E10""]",不引入外部监控先提供最小可观测字段
|
||||||
E12,单域部署(server托管client静态),支持server托管client dist实现同域cookie并提供开关与SPA回退,开启后通过server根路径访问前端且登录刷新正常,"manual: cd client && npm run build 然后SERVE_CLIENT_DIST=true启动server访问http://127.0.0.1:3000完成登录",manual,TODO,TODO,TODO,server/src/app.js | server/src/config.js | server/README.md | docker-compose.yml,"[""E10""]",优先复用@fastify/static并用正则fallback避开/api
|
E12,单域部署(server托管client静态),支持server托管client dist实现同域cookie并提供开关与SPA回退,开启后通过server根路径访问前端且登录刷新正常,"manual: cd client && npm run build 然后SERVE_CLIENT_DIST=true启动server访问http://127.0.0.1:3000完成登录",manual,DONE,TODO,TODO,server/src/app.js | server/src/config.js | server/README.md | docker-compose.yml,"[""E10""]",优先复用@fastify/static并用正则fallback避开/api
|
||||||
E13,部署与运维包(试运行),提供deploy/trialrun含env模板compose示例反向代理示例与启动停止runbook,按runbook可在新机器完成启动升级迁移与回滚演练一次,"manual: 按deploy/trialrun/runbook执行docker compose启动并验证ready与登录与回归入口",manual,TODO,TODO,TODO,deploy/trialrun/* | docker-compose.yml | server/.env.example | server/README.md | client/README.md,"[""E12""]",先覆盖试运行环境后续再细化生产
|
E13,部署与运维包(试运行),提供deploy/trialrun含env模板compose示例反向代理示例与启动停止runbook,按runbook可在新机器完成启动升级迁移与回滚演练一次,"manual: 按deploy/trialrun/runbook执行docker compose启动并验证ready与登录与回归入口",manual,TODO,TODO,TODO,deploy/trialrun/* | docker-compose.yml | server/.env.example | server/README.md | client/README.md,"[""E12""]",先覆盖试运行环境后续再细化生产
|
||||||
E14,备份与恢复演练(DB+文件),提供DB(pg_dump)与文件存储(票据上传目录)备份恢复脚本并把演练步骤写入runbook,可生成DB+文件备份并在空库与空存储恢复后通过smoke与登录验证,"manual: 执行备份脚本后restore到新库与新存储目录并跑smoke-test",manual,TODO,TODO,TODO,scripts/backup-db.sh | scripts/restore-db.sh | scripts/backup-storage.sh | scripts/restore-storage.sh | deploy/trialrun/* | server/README.md,"[""E13""]",脚本读取DATABASE_URL与STORAGE_DIR不写死密码并输出可审计日志
|
E14,备份与恢复演练(DB+文件),提供DB(pg_dump)与文件存储(票据上传目录)备份恢复脚本并把演练步骤写入runbook,可生成DB+文件备份并在空库与空存储恢复后通过smoke与登录验证,"manual: 执行备份脚本后restore到新库与新存储目录并跑smoke-test",manual,TODO,TODO,TODO,scripts/backup-db.sh | scripts/restore-db.sh | scripts/backup-storage.sh | scripts/restore-storage.sh | deploy/trialrun/* | server/README.md,"[""E13""]",脚本读取DATABASE_URL与STORAGE_DIR不写死密码并输出可审计日志
|
||||||
E15,Playwright E2E(上线门禁),引入Playwright覆盖主链路登录待办审批报销提交票据上传OCR轮询绑定预览支付登出,本地与CI均可稳定跑通且失败输出可定位trace与截图,cd client && npm run test:e2e,manual,TODO,TODO,TODO,client/playwright.config.ts | client/tests/e2e/* | client/package.json | client/README.md | client/src/pages/*,"[""E12"",""E4"",""E5"",""E6"",""E8""]",测试环境OCR_PROVIDER=mock且OCR_POLL_CRON改为秒级以保证E2E稳定
|
E15,Playwright E2E(上线门禁),引入Playwright覆盖主链路登录待办审批报销提交票据上传OCR轮询绑定预览支付登出,本地与CI均可稳定跑通且失败输出可定位trace与截图,cd client && npm run test:e2e,manual,TODO,TODO,TODO,client/playwright.config.ts | client/tests/e2e/* | client/package.json | client/README.md | client/src/pages/*,"[""E12"",""E4"",""E5"",""E6"",""E8""]",测试环境OCR_PROVIDER=mock且OCR_POLL_CRON改为秒级以保证E2E稳定
|
||||||
|
|||||||
|
+15
@@ -179,6 +179,19 @@ npm run dev
|
|||||||
- 通过 Vite 代理将 `/api` 转发到 `http://127.0.0.1:3000`。
|
- 通过 Vite 代理将 `/api` 转发到 `http://127.0.0.1:3000`。
|
||||||
- Access Token 仅保存在内存,Refresh Token 通过 HttpOnly Cookie 续签。
|
- Access Token 仅保存在内存,Refresh Token 通过 HttpOnly Cookie 续签。
|
||||||
|
|
||||||
|
### 4.7 单域托管 client/dist(可选,试运行建议)
|
||||||
|
```bash
|
||||||
|
cd /home/shunli/Projects/hyb-oa/client
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
cd /home/shunli/Projects/hyb-oa/server
|
||||||
|
SERVE_CLIENT_DIST=true npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 访问 `http://127.0.0.1:3000` 即可打开前端(同域 cookie,减少 CORS 复杂度)。
|
||||||
|
- SPA 路由(如 `/dashboard`)会回退到 `index.html`;`/api/*` 不参与回退。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. P2 API 摘要
|
## 5. P2 API 摘要
|
||||||
@@ -231,6 +244,8 @@ npm run job:ocr-once
|
|||||||
- `JWT_SECRET`: JWT & signed-url token 签名密钥
|
- `JWT_SECRET`: JWT & signed-url token 签名密钥
|
||||||
- `APP_TIMEZONE`: 默认 `Asia/Shanghai`
|
- `APP_TIMEZONE`: 默认 `Asia/Shanghai`
|
||||||
- `STORAGE_DIR`: 默认 `generated/storage`(相对 `server/`)
|
- `STORAGE_DIR`: 默认 `generated/storage`(相对 `server/`)
|
||||||
|
- `SERVE_CLIENT_DIST`: 是否由 server 托管 `client/dist`(默认 `false`)
|
||||||
|
- `CLIENT_DIST_DIR`: 前端构建产物目录(默认 `../client/dist`,相对 `server/`)
|
||||||
- `SIGNED_URL_TTL_SECONDS`: 默认 `600`
|
- `SIGNED_URL_TTL_SECONDS`: 默认 `600`
|
||||||
- `MAX_UPLOAD_SIZE_BYTES`: 默认 `10485760`(10MB)
|
- `MAX_UPLOAD_SIZE_BYTES`: 默认 `10485760`(10MB)
|
||||||
- `RATE_LIMIT_ENABLED`: 是否启用关键接口限流(默认:production=true,其它环境=false)
|
- `RATE_LIMIT_ENABLED`: 是否启用关键接口限流(默认:production=true,其它环境=false)
|
||||||
|
|||||||
+20
-1
@@ -9,6 +9,7 @@ const cookie = require("@fastify/cookie");
|
|||||||
const multipart = require("@fastify/multipart");
|
const multipart = require("@fastify/multipart");
|
||||||
const helmet = require("@fastify/helmet");
|
const helmet = require("@fastify/helmet");
|
||||||
const rateLimit = require("@fastify/rate-limit");
|
const rateLimit = require("@fastify/rate-limit");
|
||||||
|
const staticPlugin = require("@fastify/static");
|
||||||
|
|
||||||
const { getConfig } = require("./config");
|
const { getConfig } = require("./config");
|
||||||
const authPlugin = require("./plugins/auth");
|
const authPlugin = require("./plugins/auth");
|
||||||
@@ -95,11 +96,29 @@ async function buildApp() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (config.serveClientDist) {
|
||||||
|
await app.register(staticPlugin, {
|
||||||
|
root: config.clientDistDir,
|
||||||
|
});
|
||||||
|
app.log.info({ dir: config.clientDistDir }, "Client dist static serving enabled");
|
||||||
|
}
|
||||||
|
|
||||||
app.addHook("onRequest", async (request, reply) => {
|
app.addHook("onRequest", async (request, reply) => {
|
||||||
reply.header("x-request-id", request.id);
|
reply.header("x-request-id", request.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.setNotFoundHandler(async () => {
|
app.setNotFoundHandler(async (request, reply) => {
|
||||||
|
if (
|
||||||
|
config.serveClientDist &&
|
||||||
|
request.method === "GET" &&
|
||||||
|
!request.url.startsWith("/api") &&
|
||||||
|
typeof request.headers.accept === "string" &&
|
||||||
|
request.headers.accept.includes("text/html") &&
|
||||||
|
typeof reply.sendFile === "function"
|
||||||
|
) {
|
||||||
|
return reply.type("text/html").sendFile("index.html");
|
||||||
|
}
|
||||||
|
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "SYS_404",
|
code: "SYS_404",
|
||||||
message: "Not Found",
|
message: "Not Found",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ function getConfig() {
|
|||||||
const nodeEnv = process.env.NODE_ENV ?? "development";
|
const nodeEnv = process.env.NODE_ENV ?? "development";
|
||||||
const serverRoot = path.resolve(__dirname, "..");
|
const serverRoot = path.resolve(__dirname, "..");
|
||||||
const storageDir = process.env.STORAGE_DIR ?? "generated/storage";
|
const storageDir = process.env.STORAGE_DIR ?? "generated/storage";
|
||||||
|
const clientDistDir = process.env.CLIENT_DIST_DIR ?? "../client/dist";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nodeEnv,
|
nodeEnv,
|
||||||
@@ -40,6 +41,8 @@ function getConfig() {
|
|||||||
|
|
||||||
appTimezone: process.env.APP_TIMEZONE ?? "Asia/Shanghai",
|
appTimezone: process.env.APP_TIMEZONE ?? "Asia/Shanghai",
|
||||||
storageDir: path.resolve(serverRoot, storageDir),
|
storageDir: path.resolve(serverRoot, storageDir),
|
||||||
|
serveClientDist: process.env.SERVE_CLIENT_DIST === "true",
|
||||||
|
clientDistDir: path.resolve(serverRoot, clientDistDir),
|
||||||
signedUrlTtlSeconds: Number(process.env.SIGNED_URL_TTL_SECONDS ?? 600),
|
signedUrlTtlSeconds: Number(process.env.SIGNED_URL_TTL_SECONDS ?? 600),
|
||||||
maxUploadSizeBytes: Number(process.env.MAX_UPLOAD_SIZE_BYTES ?? 10 * 1024 * 1024),
|
maxUploadSizeBytes: Number(process.env.MAX_UPLOAD_SIZE_BYTES ?? 10 * 1024 * 1024),
|
||||||
|
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户