一个功能完整的租车公司管理解决方案,支持车辆调度、订单管理、客户管理、财务统计等核心业务功能。
前后端一体部署在 Cloudflare Worker 上:Vue 前端构建产物由 Worker 的 Static Assets 托管,API、页面、上传文件共用同一个 Worker 与同一个域名,只需要发布一次。
- 开发速查(AI Agent 向):AGENTS.md
- 历史变更:CHANGELOG.md
| 层 | 选型 |
|---|---|
| 运行时 | Cloudflare Workers |
| 后端框架 | Hono 4 + TypeScript |
| 数据库 | Cloudflare D1(SQLite 兼容),30 张表 |
| 对象存储 | Cloudflare R2(上传的图片 / PDF,读取一律走签名 URL) |
| 认证 | JWT(7 天有效期,Web Crypto,可按用户吊销) |
| 定时任务 | Cloudflare Cron Triggers(清理过期日志与登录失败计数) |
| 前端 | Vue 3 + Vite + TypeScript + Element Plus(按需引入)+ Pinia |
| 验证 | vitest(后端纯逻辑单测)+ biome(lint)+ tsc / vue-tsc(类型检查) |
zuche/
├── src/ # Worker 源码
│ ├── index.ts # Hono 出口:secure-headers、/health、/uploads/*、
│ │ # onError/notFound、scheduled(cron 清理)
│ ├── routes/
│ │ ├── index.ts # /api 下全部业务路由
│ │ └── upload.ts # 7 个上传端点 + 从 R2 读回(校验签名)
│ ├── controllers/ # 业务控制器(auth/users/customers/vehicles/orders/
│ │ # orderSources/violations/maintenance/insurance/
│ │ # inspection/dashboard/schedules/settings/logs/
│ │ # blacklist/import;财务:fundAccount/owners/
│ │ # finance/expenses/settlement/reports)
│ ├── db/
│ │ ├── helpers.ts # query/queryOne/execute/queryWithPagination/batchExecute
│ │ └── rows.ts # 各表行类型
│ ├── lib/ # 纯逻辑,单测就覆盖这一层:
│ │ # auth(jwt 签发/校验) / constants(枚举中文名) / errors
│ │ # ids(单号) / json(历史 JSON 形态兼容) / log(操作日志)
│ │ # orderAmount(整元金额计算) / request / time(北京时间)
│ │ # uploadGuard(文件魔数) / uploadUrl(签名 URL)
│ │ # vehicles(在租状态派生)
│ │ # 财务专用:money(6 位精度金额) / settlement(结算计算链
│ │ # +SQL 构造) / ledger(资金流水与红字冲销) /
│ │ # fundAccount(支付方式→账户) / expenseMirror(业务单据
│ │ # →车辆费用镜像)
│ │ └── import/ # 批量导入:normalize(列映射) / templates(平台模板) /
│ │ # validate(导入预检)
│ ├── middleware/auth.ts # authMiddleware、adminOnly
│ └── types.ts # Bindings、AppContext
│
├── migrations/ # D1 迁移 SQL(17 个)
│ ├── 0001_schema.sql # 13 张基础表
│ ├── 0002_indexes.sql
│ ├── 0003_seed.sql # 管理员账号等种子数据
│ ├── 0004_import.sql # 批量导入:order_fees / order_extensions / import_batches
│ ├── 0005_drop_order_source_platform.sql # 订单来源不再区分所属平台
│ ├── 0006_vehicle_license_images.sql # 行驶证改为多图(JSON 数组)
│ ├── 0007_timezone_unify.sql # 存量时间统一到北京时间(含数据修正)
│ ├── 0008_auth_and_snapshot.sql # 强制改密 / 令牌吊销 / login_attempts /
│ │ # 订单车牌快照
│ ├── 0009_vehicle_status_derive.sql # 车辆「已出租」改为由订单推导
│ ├── 0010_missing_indexes.sql
│ ├── 0011_brand_color.sql # 订单来源默认色归一成品牌蓝
│ ├── 0012_finance_owners.sql # 车主档案 + vehicles 档案字段
│ ├── 0013_fund_accounts.sql # 资金账户 / 流水 / 划转 / 账期锁定
│ ├── 0014_settlement.sql # 车主结算行 / 期初结转 / 结算付款
│ ├── 0015_expense_ledgers.sql # 车辆费用 / 运营开支 / 合伙人往来 / 订单开票列
│ ├── 0016_finance_seed.sql # 6 个账户 + 27 项开支字典 + 补哈啰来源
│ └── 0017_self_owned_owner.sql # 「公司自营」受益人 + 自营车回填
├── scripts/
│ ├── seed-demo.sql # 本地演示数据
│ ├── dev-backend.mjs # 应急本地后端(Node 版,见「本地开发」)
│ └── verify-finance.mjs # 财务只读体检(外键/幂等键/冲销链/余额重算)
├── test/ # vitest 单测:10 个文件,只测 src/lib 的纯逻辑
└── frontend/src/ # 前端源码(构建产物 frontend/dist,不入库)
├── api/
│ ├── index.ts # API 封装,baseURL 为相对路径 /api
│ └── types.ts # 响应信封 / 分页 / 各实体类型
├── components/
│ ├── order/ # 订单域:建单表单、延期、支付、司机指派、里程照片、
│ │ # 订单详情分节(6 个组件)
│ ├── dashboard/ # 仪表盘(总览):甘特图、调度表、订单详情弹窗
│ ├── vehicle/ # 车辆域弹窗与选择器:保养/保险/违章表单、车辆选择
│ ├── finance/ # 财务域:6 个页签(资金流水/车主结算/车辆费用/
│ │ # 运营开支/车主/报表)+ 9 个弹窗
│ ├── ImagePreviewDialog.vue、DataState.vue # 通用展示:图片预览、空/加载/错误态
│ └── *Tab.vue # 业务分栏:Vehicles / Maintenance / Insurance /
│ # Inspection / Violations / OrderSources / Users
├── composables/ # useMobile(响应式断点)、useQuerySync(筛选同步到 URL)
├── layouts/ # MainLayout
├── router/ # 路由(createWebHistory)
├── stores/ # Pinia:user(登录态)、dict(来源/品牌等字典)
├── utils/ # constants(枚举与映射)、helpers(格式化与状态映射)、
│ # image(上传前压缩)、upload(上传前校验)、
│ # money(金额格式化,保留台账的 4 位小数)
├── views/ # 页面:Login/Dashboard/Orders/OrderDetail/OrderImport/
│ # Customers/Vehicles/Finance/Settings/Logs/NotFound
└── style.css # 全局样式 + Apple 设计 token(--sk-*)
⚠️ 用户管理、订单来源与违章没有独立页面:入口分别是Settings.vue里的<UsersTab />与<OrderSourcesTab />,以及Vehicles.vue的<ViolationsTab />。别按菜单入口去找同名页面。财务的 6 块内容也不各自占一个路由:都挂在
/finance的页签下(views/Finance.vue是薄壳, 组件在components/finance/),支持?tab=深链。加新页签时记得同步VALID_TABS。
订单状态流转:
pending (待取车) → active (已取车) → completed (已还车)
↘ cancelled (已取消)
overdue(已逾期)不是库里的状态,而是派生出来的:active 且 end_date 早于当前时间的订单即视为逾期,
展示与筛选都走派生,不落库、也不需要定时任务去改写它。
支付方式: platform(平台支付)、wechat(微信)、alipay(支付宝)、cash(现金)、bank(银行卡)、other(其他)
支付类型: rent(租金)、deposit(押金)、rent_deposit(租金+押金)、violation_deposit(违章押金)、damage(车损)、other(其他)
调度生成规则:
- 送车任务:从待取车 (pending) 订单提取取车时间和位置
- 收车任务:从已取车 (active) 订单提取还车时间和位置
- 仅显示当前时间及以后的调度,按时间升序排列
| 模块 | 功能 |
|---|---|
| 总览(仪表盘) | 库存日历(全部车辆 × 往前 30 天/往后 60 天的订单占用,支持按日期定位)与待收送表 |
| 订单管理 | 状态分类、服务类型(基础/优享/尊享)、免押与免押到期日、取还车(时间/地点/里程/照片)、延期、支付记录、合同号、司机指派 |
| 订单批量导入 | 上传携程 / 自有平台导出的 Excel → 预检(逐行校验 + 与客户车辆匹配)→ 提交入库 → 按批次撤销 |
| 客户管理 | 客户信息、常用客户标记、来源标记、证件与驾照照片、快速查看历史订单 |
| 车辆管理 | 车辆 CRUD、新能源标识、VIN/发动机号、行驶证多图(正副页)、详情弹窗;分页签承载保养/保险/年检/违章 |
| 违章管理 | 违章登记、罚款与扣分、多图上传、处理状态跟踪、押金扣款(车辆页「违章」分栏) |
| 黑名单 | 添加/移除、原因记录、手机号与身份证检查(客户管理页「黑名单」分栏) |
| 保养管理 | 按车辆查看、保养类型(常规/大保养/小修)、里程提醒、成本统计 |
| 保险管理 | 按车辆查看、险种多选(交强险/商业险/座位险)、到期提醒、保费统计 |
| 年检证 | 年检状态列表、证书图片、到期提醒 |
| 订单来源 | 渠道管理(门店直租/美团/携程/滴滴等)、佣金比例、渠道颜色(设置页分栏) |
| 用户管理 | 用户 CRUD、角色 admin/staff、改密与重置密码(设置页分栏) |
| 系统设置 | Logo、系统标题 |
| 操作日志 | 关键操作记录(操作类型、对象、详情、时间、IP)(设置页分栏) |
| 财务 · 资金流水 | 多账户(公户/微信/支付宝/现金/平台待结算/待归属)、逐行余额、手工记账、改归属、红字冲销、账户划转、账期锁定 |
| 财务 · 车主结算 | 按账期生成结算行(合计 → 平台管理费 → 结算金额 → 公司管理费 → 车主结算金额)、人工调整金额、作废/恢复、期初结转、结算付款、对账单导出 |
| 财务 · 车辆费用 | 收支双列台账(车损赔偿等收入与维修/洗车/过路费等支出同一张表)、发票状态、标记/撤销付款、保养保险违章自动镜像 |
| 财务 · 运营开支 | 27 类项目字典、微信/公户付款、按项目与月份汇总、应付未付跟踪 |
| 财务 · 车主 | 车主与合伙人档案(一表两角色)、公司费率、结算对账单、合伙人往来账(开办费/垫资/工资/下账) |
| 财务报表 | 单车月报(月供/保养/维修/结余)、车辆收益排行、公司月度经营报表、资金流水报表 |
| 口径 | 数据来源 | 特点 |
|---|---|---|
| 经营收入 | payments(排除已取消订单) |
仪表盘的「本月收入」,按收款时刻计 |
| 结算收入 | settlement_lines |
按账期归属、精确到 4 位小数(台账口径),报表以它为准 |
| 现金余额 | fund_accounts.opening_balance + fund_transactions 净额 |
现金收付制,余额是派生值、库里不存 |
具体差别:
orders.net_amount是整元(lib/orderAmount.ts的Math.round),而结算行的平台管理费保留 4 位小数 (702.95 × 15% = 105.4425)。同一张单这两个数字允许差几分,报表以结算行为准。- 资金流水是现金口径:订单取消不冲销已收的钱(钱确实收过),退款要单独记一笔; 删除订单才写红字冲销(单据不该存在)。
- 权责发生 vs 现金收付:车辆费用/运营开支只有「已付款」才写资金流水,未付款的只进费用台账(应付)。
- 余额不按
opening_date过滤:opening_balance是「系统里第一条流水之前」的余额, 录入的流水都叠加在它之上。按日期过滤会让补录的历史流水静默消失。
平台管理费 = 合计 × 平台费率 # order_sources.commission_rate,生成时快照
结算金额 = 合计 − 平台管理费
公司管理费 = 结算金额 × 公司费率 # owners.company_fee_rate,按车主配(自营为 0)
车主结算金额 = 结算金额 − 公司管理费 − 其他费用
费率在生成时快照进结算行,之后改配置不影响历史行;人工调整过的行(amount_overridden = 1)
不会被订单改价重算覆盖。
这几条是「为什么这么定」,改动前先看一眼,能省掉大量踩坑。
src/lib/time.ts 的 now() 返回北京时间(Asia/Shanghai,固定 +8),SQL 侧对应写 datetime('now','+8 hours')。
早期实现直接 toISOString() 写 UTC,而用户通过 datetime-local 录入的时间是北京时间,
同一个库里出现过两套基准:车辆「是否在租」判定错 8 小时、调度列表边界错 8 小时、日志时间显示早 8 小时。
迁移 0007_timezone_unify.sql 对存量做了分列修正:系统写入的审计列(created_at / updated_at 等)统一 +8,
用户录入的业务列(订单起止时间、保养/违章/保险日期等)一律不动。
| 状态 | 怎么来的 |
|---|---|
订单 overdue(已逾期) |
active 且 end_date < 当前时间 |
| 车辆「已出租」 | 存在 pending/active 订单且当前时间落在取还车区间内,见 src/lib/vehicles.ts 的 busyVehicleIds() |
vehicles.status 只表达人工设定的可用性(available / maintenance / unavailable),
早期它还能取 rented,但订单流转从不维护它,于是仪表盘「已出租」恒为 0、而列表又另算一套,
两处永远对不上。迁移 0009 把存量的 rented 归一成 available,是否在租一律由订单推导。
| 机制 | 现状 |
|---|---|
| JWT 有效期 | 7 天(早期是 1 年),前端存 localStorage.token |
| 令牌吊销 | JWT 里带 tv(签发时的 users.token_version),中间件逐请求比对;改密码 / 重置密码 / 禁用 / 改角色时自增 |
| 登录限流 | 15 分钟内 username:IP 失败 5 次,锁定 15 分钟(login_attempts 表)。key 带 username 是因为门店常共用出口 IP,只按 IP 会互相误伤 |
| 密码哈希 | bcrypt cost 10(约 80ms,仍在 Workers CPU 限制内;12 约 300ms,不建议) |
| 首次登录强制改密 | 种子管理员带 must_change_password=1,登录返回该标记,前端据此弹出改密弹窗 |
JWT_SECRET |
必填。src/lib/auth.ts 已删掉默认密钥兜底,缺失时登录直接报「服务未正确配置」 |
登录支持用户名、姓名、手机号、邮箱任一匹配;失败登录也会写操作日志,否则被撞库时没有任何线索。
退出登录(POST /api/auth/logout)只落审计日志,刻意不自增 token_version —— 那会把该账号在其它设备上的会话一起踢掉。
/uploads/* 不再公开可读。对外 URL 形态是 /uploads/{exp}.{sig}/{dir}/{file},
serveUpload(src/routes/upload.ts)校验 HMAC 与 7 天过期时间,无效一律 403。
- 签名放在路径里而不是 query 上:前端展示图片会拼成
/cdn-cgi/image/<opts>/<源路径>, Cloudflare 的图像转换层不透传 query,只有路径能保证签名到达 Worker。 - 签名是响应后处理统一加的:
src/lib/uploadUrl.ts的signUploadUrls中间件挂在/api上, 递归替换响应体里所有/uploads/字符串。所以新增返回图片的接口不用手动签名; 反过来,也别在接口里返回已经签好的绝对链接,交给中间件。 - 库里存规范路径即可(即使存着带签名的旧值也安全:对外前会先剥掉旧签名再重签)。
- 签名密钥由
JWT_SECRET派生(不额外引入 secret),因此JWT_SECRET缺失会让/uploads/*全部 403。
GET /health 会真探一次 D1 与 R2,返回 { status: ok|degraded, checks: { database, storage } },
degraded 时状态码 503 —— 只返回 200 的健康检查等于没有。它是公开的,不需要登录。
wrangler.jsonc 的 triggers.crons(0 18 * * *,UTC 18:00 = 北京 02:00)每天跑一次 scheduled:
清理 180 天前的操作日志与 24 小时前的登录失败计数。两者互不依赖,用 allSettled 并发,任一失败不影响另一个。
组件由 unplugin-vue-components + ElementPlusResolver 按需引入(vite.config.ts),
早期是 app.use(ElementPlus) 全量注册,tree-shaking 完全失效,主包 874KB;改完主包约 45KB,各组件独立分包。
样式仍走全量 element-plus/dist/index.css(CSS 是独立产物,不影响主 JS 体积,全量更稳,不会漏 ElMessage 这类命令式 API 的样式)。
由此带来的坑:图标不再全局注册,必须逐个 import { Plus } from '@element-plus/icons-vue'。
裸图标名不会被自动解析,字符串式 icon="Plus" 永远不显示,必须写成绑定式 :icon="Plus"。
排查「按钮/菜单图标不显示」时先看这一条。
- Node.js 20.19+ / 22.12+(Vite 8 的硬性要求;Node 后端方案用到
node:sqlite,需 22.5+) - npm
- Cloudflare 账号(仅上线时需要)
npm install
npm run devnpm run dev 同时起两个服务:
- Worker(含 D1、R2 本地模拟):http://localhost:8787
- 前端 Vite(HMR,
/api、/uploads、/cdn-cgi已代理到 8787):http://localhost:5173
首次启动前初始化本地数据库:
npm run d1:migrate # 应用 migrations/ 到本地 D1
npx wrangler d1 execute zjzc --local \
--file=./scripts/seed-demo.sql # 可选:灌入演示数据某些环境(典型是 aarch64 且用户态虚拟地址空间不足 48 位)里 workerd 一启动就 Aborted:
tcmalloc: MmapAligned() failed - unable to allocate with tag (alignment=1073741824)
CHECK in AllocSlow: FATAL ERROR: Out of memory trying to allocate internal tcmalloc data
(wrangler --version 能跑是因为它不启动 workerd。)这时改用 Node 版本地后端 scripts/dev-backend.mjs:
它用 esbuild 把同一份 src/index.ts 打成 Node ESM,起一个 node:http 服务,把绑定换成等价的本地实现
—— D1 → node:sqlite(数据文件 .wrangler/state/dev-node/data.sqlite,开启外键)、
R2 → 本地目录 .wrangler/state/dev-node/r2、caches.default → 空实现。
接口路径、签名机制与真实 Worker 一致。
node scripts/dev-backend.mjs # 默认 8787,PORT=9000 可改
npm run dev:web # 前端单独起(需要对外访问时加 -- --host 0.0.0.0)- 启动时会自动按序跑完
migrations/*.sql并灌入scripts/seed-demo.sql,不需要再手工执行迁移 - 重置数据 = 删掉
.wrangler/state/dev-node/data.sqlite JWT_SECRET默认local-dev-jwt-secret- 它不参与部署,
package.json里也没有对应 script(正常环境请用npm run dev)
配套的 Vite 代理有三条(见 vite.config.ts):/api、/uploads,以及 /cdn-cgi —— 后者会剥掉
/cdn-cgi/image/<opts> 前缀再转发,因为本机没有 Cloudflare 图像转换层,不剥的话请求会落到 SPA 回落,
图片全变成 index.html。
npm run verify # typecheck + lint + test,一条命令跑完
npm run typecheck # 前后端一起检查(worker: tsc / web: vue-tsc)
npm run typecheck:worker # 仅 Worker
npm run typecheck:web # 仅前端
npm run test # vitest run(10 个文件 / 178 个用例)
npm run test:watch # vitest 监听模式
npm run lint # biome check
npm run lint:fix # biome check --write- 单测只覆盖后端纯逻辑:时区基准(
lib/time.ts)、金额计算(lib/orderAmount.ts)、订单号(lib/ids.ts)、 签名 URL(lib/uploadUrl.ts)、上传魔数(lib/uploadGuard.ts)、历史 JSON 形态兼容(lib/json.ts)、 导入列映射(lib/import/normalize.ts)、财务金额与结算链(lib/money.ts、lib/settlement.ts、lib/ledger.ts)。跑在node环境,不涉及 Vue 组件与 D1。 改这几个模块必须先让npm run test通过。 - 财务一致性有两层体检脚本(
npm run verify不含它们,需要单独的数据库/后端):node scripts/verify-finance.mjs—— 只读本地 SQLite,检查外键完整性、幂等键唯一性、 冲销链闭合、结算行 calc/final 漂移、已付款费用是否都有流水等 34 项不变量, 并逐账户列出「期初 + 流水净额 = 余额」供人工核对。全程只 SELECT。node scripts/verify-finance-api.mjs—— 打接口,检查查询逻辑算得对不对 (逐行余额在筛选/翻页/冲销后是否仍正确)。需要后端在跑,会在临时账户上写几笔测试流水再清理。 两者退出码非 0 都表示发现不一致。上线后建议每次月结前跑一遍。 为什么必须分两层:有些错误(如余额把筛选条件写进了窗口层)在数据库里看不出任何异常, 单测也跑不到,只有把请求发出去才暴露 —— 这个坑真实发生过,接口层的两条断言就是为此加的。
- lint 只覆盖
src/**与test/**(biome.json,另含两个 vite 配置文件)。.vue被排除是刻意的:biome 不认<script setup>的模板用法,会把模板里用到的导入全误报成noUnusedImports。前端的类型与用法由vue-tsc把关。 - CI:
.github/workflows/ci.yml在 push(master/main)与所有 PR 上触发,Node 22, 依次跑npm ci→typecheck→lint→test→build:web;同一分支连续推送会取消上一次未完成的运行。 本地npm run verify覆盖其中的 typecheck + lint + test 三步(CI 多一步构建,用来挡「类型过了但打包失败」)。
- 用户名:
admin - 密码:
admin123
⚠️ 上线后第一件事就是登录并修改密码。admin123明文写在migrations/0003_seed.sql里,任何拿到这份代码的人都能直接登录你的线上系统。 种子账号带must_change_password=1,登录后前端会强制弹改密弹窗,改完才放行。
配置文件的唯一来源是 wrangler.jsonc(项目里没有也不需要 wrangler.toml)。
⚠️ fork 陷阱:wrangler.jsonc里有两处写死的是本项目真实值,搬到自己的账号前必须改:d1_databases[0].database_id(一个真实 UUID)和routes[0].pattern(真实域名nxzj.wsad.eu.org)。 前者不改会部署「成功」但线上接口全挂;后者不改会把别人的域名解析到你自己的 Worker 上。
npx wrangler login # 1. 登录 Cloudflare
npm run d1:create # 2. 创建 D1,把返回的 database_id 填进 wrangler.jsonc
npm run r2:create # 3. 创建 R2 桶
npx wrangler secret put JWT_SECRET # 4. 设置 JWT 密钥
npm run d1:migrate:remote # 5. 线上建表 + 种子数据(老库则是增量迁移)
npm run deploy # 6. 构建前端并发布(前后端一次发布)
⚠️ 速查版省略了几个必须注意的前置条件(占位符database_id、写死的routes、桶是否已存在)。第一次上线请照下面的详细步骤执行。
| 资源 | 名称 / 绑定名 | 说明 | 是否需要手动创建 |
|---|---|---|---|
| Worker | rental-admin |
唯一的部署单元,含前端静态资源与 cron | 否,wrangler deploy 自动创建 |
| D1 数据库 | zjzc,绑定 DB |
17 张业务表 | 是,npm run d1:create |
| R2 存储桶 | zjzc,绑定 UPLOADS |
上传的图片 / PDF | 是,npm run r2:create |
| Static Assets | 绑定 ASSETS,目录 ./frontend/dist |
前端产物,每次发布重新打包上传 | 否 |
| Cron Trigger | wrangler.jsonc 的 triggers.crons |
每日清理过期日志与登录失败计数 | 否,随部署生效 |
| Secret | JWT_SECRET |
JWT 签名密钥 + 上传 URL 签名密钥 | 是,wrangler secret put |
| 访问域名 | wrangler.jsonc 的 routes 指定,或默认 *.workers.dev |
仓库里写死的是 nxzj.wsad.eu.org,fork 后必须改 |
否 |
npx wrangler login # 浏览器授权
npx wrangler whoami # 确认身份,并拿到 Account ID无浏览器环境(服务器 / CI)改用 API Token:
export CLOUDFLARE_API_TOKEN=你的令牌
export CLOUDFLARE_ACCOUNT_ID=0123456789abcdef0123456789abcdefnpm run d1:create # = wrangler d1 create zjzc把输出里的 database_id 填进 wrangler.jsonc:
⚠️ 仓库里当前的database_id(8ec3c62f-...)指向本项目已建好的库。换成自己的库时必须替换成真实 UUID,否则wrangler deploy依然会显示成功,但线上所有接口都会报错(找不到该 D1)。这是最容易踩的一步。
npx wrangler d1 list # 确认库已存在
npx wrangler d1 info zjzc # 查看详情npm run r2:create # = wrangler r2 bucket create zjzc
npx wrangler r2 bucket list # 确认
⚠️ 部署不会自动创建桶。桶缺失时页面能打开,但图片上传会失败(The specified bucket does not exist)。桶名必须与wrangler.jsonc的bucket_name一致。
/uploads/* 由 Worker 从 R2 读回后返回,不是公开桶,不需要配置公开访问、CORS 或自定义域;
而且读取必须带有效签名(见「关键机制:上传文件」)。
node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" # 生成强随机值
npx wrangler secret put JWT_SECRET # 粘贴进去
npx wrangler secret list # 确认(只列名字)
⚠️ 必须设置。src/lib/auth.ts已不再提供默认密钥:JWT_SECRET缺失时登录接口会直接报「服务未正确配置」,/uploads/*也会因为无法校验签名而全部 403。改完代码后若不设置,整个系统不可用。⚠️ 更换JWT_SECRET会让所有已签发的 token 立即失效(JWT 有效期 7 天,前端存localStorage.token),换密钥请挑低峰期。 ℹ️ 上传文件的签名 URL 用JWT_SECRET派生出的子密钥签名,换密钥会让已加载页面里的图片链接失效,刷新即可。
npm run d1:migrate:remote # = wrangler d1 migrations apply zjzc --remote- 迁移是增量的,执行过的不会重复执行,重复跑是安全的
0003_seed.sql用INSERT OR IGNORE写入管理员账号,再跑一次不会覆盖已改过的密码- 某个迁移报错时只有该迁移被回滚,之前成功的保持原样
两个需要特别对待的迁移:
0007_timezone_unify.sql含存量数据修正:把系统写入的审计列统一 +8 到北京时间,用户录入的业务列不动。 上线前先npx wrangler d1 export zjzc --remote --output=backup-before-0007.sql备份, 跑完抽查几条订单的created_at与start_date是否符合预期。0008_auth_and_snapshot.sql引入令牌吊销(users.token_version,配合代码里 JWT 有效期从 1 年改为 7 天): 上线后所有人都会被踢下线、需要重新登录,种子管理员还会被标记为强制改密。安排在低峰期,并提前通知门店。
从旧版本升级时,需要执行的完整迁移是 0004–0011(0001–0003 建库时已执行)。
npm run deploy # = npm run build:web && wrangler deploy输出末尾会给出访问地址和 Current Version ID,记下 Version ID,回滚时要用到。
⚠️ 若报The directory specified by the assets.directory field does not exist: frontend/dist,说明没跑构建。frontend/dist被.gitignore忽略,每次部署都必须重新构建——用npm run deploy,不要裸跑wrangler deploy。
curl https://<你的域名>/health
# {"status":"ok","checks":{"database":true,"storage":true},"timestamp":"..."}
# ← 真探了 D1 与 R2;任一项 false 会返回 degraded 且状态码 503
curl https://<你的域名>/api/nope
# {"success":false,"message":"接口不存在"} ← 说明 /api/* 正确进了 Worker,而不是落到静态资源浏览器打开根路径,用 admin / admin123 登录后立刻修改密码(会被强制弹窗)。
前提:域名必须托管在同一个 Cloudflare 账号下。 前提:
vite.config.ts没有配置base,前端资源用绝对路径/assets/...,所以只能部署在根路径,不能绑example.com/rental/这类带子路径的域名,否则静态资源全部 404。
方式一:写进 wrangler.jsonc(推荐,可复现)
{
"routes": [
{ "pattern": "rental.example.com", "custom_domain": true }
]
}然后 npm run deploy。从配置里删掉这条再发布,域名会被解绑。
方式二:命令行一次性指定
npx wrangler deploy --domain rental.example.com方式三:Dashboard
Workers & Pages → rental-admin → Settings → Domains & Routes → Add → Custom domain。
Custom Domain 会自动写入 DNS 记录和 SSL 证书,不需要手动加 CNAME。前端无需改代码:frontend/src/api/index.ts 的 baseURL 是相对路径 /api,图片也用后端返回的 /uploads/... 相对路径,换域名自动生效。
git pull
npm ci # 依赖有变化时
npm run verify # 必做(typecheck + lint + test,与 CI 保持一致)
npm run deploy
npm run d1:migrate:remote # 仅当 migrations/ 新增了文件时需要npx wrangler deployments list # 最近 10 次部署
npx wrangler versions list # 最近 10 个版本
npx wrangler rollback <version-id> -m "回滚原因:xx 版本发布后订单列表白屏"
npx wrangler tail # 实时日志(排查线上错误)
⚠️ 回滚只回滚代码,不回滚数据库。 新版带过删列 / 改列的迁移时,回滚代码不会把数据库改回去。回滚前先d1 export备份,必要时手写补偿迁移或用 D1 Time Travel 恢复。
灰度发布(可选):
npx wrangler versions upload --message "v2.1: 新增催缴记录导出"
npx wrangler versions deploy <version-id>@10% # 也可 @50%,比例可在 Dashboard 调整方式 A:Workers Builds(推荐,无需配 Secret)
Workers & Pages → rental-admin → Settings → Builds → 关联 GitHub/GitLab 仓库。Cloudflare 会自动生成 API Token,push 即构建发布。
| 配置项 | 值 |
|---|---|
| Git branch | 你的默认分支(默认是 main,仓库用 master 要手动改) |
| Build command | npm ci && npm run build:web |
| Deploy command | npx wrangler deploy(默认) |
| Root directory | 留空(项目在仓库根) |
| Non-production branch builds | 建议保持关闭 |
注意两点:
wrangler deploy要读frontend/dist,构建命令不能省,否则上传的是空目录- Cloudflare 自动生成的 Token 不含 D1 编辑权限,迁移仍需在本地跑
npm run d1:migrate:remote
若构建报 Node 版本错误,在 Build 的环境变量里加 NODE_VERSION=22。
方式 B:GitHub Actions
仓库里已经有 .github/workflows/ci.yml,但它做的是校验,不是部署:push 到 master / main 与所有 PR 时触发
(同一分支连续推送会取消上一次未完成的运行),在 Node 22 上依次跑
npm ci → npm run typecheck → npm run lint → npm run test → npm run build:web。
也就是说它能挡住「类型不过 / lint 报错 / 单测挂 / 打包失败」的提交,但不会发布任何东西。
要 push 即部署,在这个 workflow 的 verify job 之后加一个 deploy job:
deploy:
needs: verify
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
- run: npm ci
- run: npm run build:web # wrangler deploy 要读 frontend/dist,不能省
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy仓库 Settings → Secrets and variables → Actions 需要两个 Secret:
| Name | Value |
|---|---|
CLOUDFLARE_API_TOKEN |
https://dash.cloudflare.com/profile/api-tokens 创建,模板选「编辑 Cloudflare Workers」;要用 CI 跑迁移再加 账号 - D1 - 编辑 |
CLOUDFLARE_ACCOUNT_ID |
wrangler whoami 输出里的 Account ID |
⚠️ 不要把JWT_SECRET放进 CI 每次重设。 Secret 每次被换成新值都会让全体已登录用户掉线,本地wrangler secret put之后就不再动它。⚠️ 首次部署仍需在本地手工做一遍:创建 D1 / R2、回填database_id、secret put都是一次性资源准备,CI 只负责后续发布。⚠️ 两种方式只选一种,同时开启会重复部署。
# 备份(结构 + 数据)
npx wrangler d1 export zjzc --remote --output=backup-$(date +%Y%m%d-%H%M).sql
npx wrangler d1 export zjzc --remote --no-data --output=schema-only.sql
npx wrangler d1 export zjzc --remote --no-schema --output=data-only.sql
# 恢复(没有 d1 import,就是把 SQL 灌回去)
npx wrangler d1 execute zjzc --remote --file=backup-20260918-1200.sql
# Time Travel
npx wrangler d1 time-travel info zjzc
npx wrangler d1 time-travel restore zjzc --timestamp=2026-09-18T10:00:00Z
# 本地数据搬到线上
npx wrangler d1 export zjzc --local --output=local-dump.sql
npx wrangler d1 execute zjzc --remote --file=local-dump.sql
⚠️ 导入前确认wrangler.jsonc的database_id指向目标库。导出的 SQL 含DROP TABLE/CREATE TABLE,会覆盖同名表数据,先备份。
新增迁移的流程:migrations/ 下新建 NNNN_xxx.sql → npm run d1:migrate 本地验证 → npm run d1:migrate:remote 上线 → 迁移文件随代码一起提交。
| 现象 | 原因 | 解决 |
|---|---|---|
部署成功但所有接口报错,日志含 Couldn't find D1 DB |
database_id 还是占位符 / 别人的 UUID |
用 npx wrangler d1 info zjzc 取真实 UUID 填回配置,重新 npm run deploy |
上传图片失败,报 The specified bucket does not exist |
R2 桶没建或名字对不上 | npm run r2:create,确认桶名与 bucket_name 一致 |
...assets.directory field does not exist: frontend/dist |
没构建前端 | 用 npm run deploy(含 build:web) |
| 部署后页面是旧的 | 浏览器缓存或漏了构建 | 强刷(Cmd/Ctrl+Shift+R);确认输出里有 Uploaded |
刷新子路由(如 /orders)404 |
assets.not_found_handling 被改掉 |
保持 single-page-application |
| API 返回 HTML 而不是 JSON | /api/* 没走 Worker |
确认 assets.run_worker_first 包含 /api/*(完整应为 ["/api/*", "/uploads/*", "/health"]) |
线上 /health 返回 index.html 或 404 |
/health 没进 run_worker_first,被 SPA 回落吃掉了 |
保持 run_worker_first 为 ["/api/*", "/uploads/*", "/health"] 三项 |
| 图片全部 403 / 打不开 | JWT_SECRET 未配置,或签名过期(7 天) |
确认已 secret put JWT_SECRET;刷新页面重新取签名链接 |
换完 JWT_SECRET 后所有人被踢下线 |
正常现象:旧 token 与旧签名都失效 | 重新登录即可;换密钥挑低峰期 |
| 上传报超过 10MB / 类型不支持 | 后端硬限制:10MB,仅 insurance 允许 PDF;并按文件头校验真实格式 |
前端上传前已自动压缩(≤500KB/WebP),仍报错说明压缩被跳过(PDF/GIF)或压缩失败;表单字段名必须固定为 image |
| 界面按钮/菜单的图标不显示 | 图标按需引入后必须逐个 import,且要写成 :icon="Plus" |
见「关键机制」里的图标约束;icon="Plus" 永远不显示 |
| 本地图片全变成 index.html | 本地没有图像转换层,/cdn-cgi/... 落到 SPA 回落 |
Vite 已配 /cdn-cgi 代理剥前缀;直连后端时改用不带 /cdn-cgi 的路径 |
wrangler dev 启动即 Aborted(tcmalloc / 虚拟地址空间) |
本机 workerd 起不来 | 改用 node scripts/dev-backend.mjs(见「本地开发」) |
| 迁移执行失败 | 目标表/列已存在,或 SQL 语法问题 | 按报错行号修 SQL;已成功的迁移不会被回退 |
wrangler: command not found |
依赖没装或没走 npx | npm install;脚本里统一用 npx wrangler ... |
-
wrangler.jsonc的database_id已换成真实 UUID(不是00000000-...,也不是 fork 来源仓库里的 UUID) -
wrangler.jsonc的routes已换成自己的域名(仓库里写死的是nxzj.wsad.eu.org) - R2 桶
zjzc已创建,桶名与配置一致 - 已
wrangler secret put JWT_SECRET(必须,代码里已无默认密钥兜底;它同时是上传 URL 的签名密钥) - 已执行
npm run d1:migrate:remote,且0004–0011全部跑完 - 上线前已备份:
npx wrangler d1 export zjzc --remote --output=backup-$(date +%Y%m%d).sql(0007含存量数据修正、0008会让全员重新登录,建议低峰期执行) - 已登录并改掉
admin123(会强制弹出改密弹窗,改完才放行) - 页面级安全响应头已在 Zone 层配置(Worker 的
secure-headers只覆盖/api/*与/uploads/*, HTML/JS/CSS 由 Static Assets 直接返回):Cloudflare Dashboard → 规则 → Transform Rules → Modify Response Header,对/**添加Content-Security-Policy、X-Frame-Options: DENY、Strict-Transport-Security。 建议先用Content-Security-Policy-Report-Only观察一轮再切强制 -
npm run verify通过(typecheck + lint + test,与 CI 一致) - 备份策略已就绪(定期
d1 export)
npm audit 会报 xlsx@0.18.5 的 Prototype Pollution(GHSA-4r6h-8v6p-xvw6,CVSS 7.8)。
SheetJS 在 0.18.5 之后不再往 npm 发布,受影响范围是 <0.19.3,npm audit 提示 npm 上暂无修复版本。
风险判断:影响面有限。xlsx 只在前端「订单批量导入」页用于解析用户自己选择的 Excel 文件, 触发条件是自己上传一个恶意构造的表格,不涉及服务端解析或他人投递,因此暂不阻断上线。
为什么至今没换:唯一的修复路径是从 SheetJS 官方源安装(非 npm registry), 而 CI(GitHub Actions)与 Cloudflare Builds 的网络环境不一定能访问该地址, 一旦拉不到包就是构建直接失败,属于用确定的构建风险去换一个不确定的低危漏洞,因此暂缓。
修复方式(确认构建环境可达后再做),API 完全兼容,改动仅一行 package.json:
npm i https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz- 免费额度:D1 5GB 存储 + 每日读写额度;Workers 每日 10 万请求;Static Assets 单文件上限 10MiB
- 产物体积(按需引入后,远低于上限):主包
index-*.js约 45KB(gzip 约 18KB), 最大单文件是xlsx的 chunk 约 424KB,全量 CSS 约 384KB - R2 需绑定支付方式(即便完全在免费额度内),免费额度为每月 10GB 存储,出流量免费
wrangler.jsonc的observability.enabled控制 Workers Logs,当前为true(开启,用于线上排查);若在意日志存储开销可改回false/uploads/*配了run_worker_first,每次读取都会进 Worker(有caches.default缓存兜底)并产生一次 R2 读操作; 图片 URL 现在是带签名的(/uploads/{exp}.{sig}/{dir}/{file}),换JWT_SECRET会让旧链接失效- 每日 cron 会清理 180 天前的操作日志,长期看 D1 存储不会无限增长,但每次清理前数据库仍在增长,备份与额度检查仍要定期做
| 表名 | 说明 |
|---|---|
| users | 用户表(含 token_version 令牌吊销、must_change_password 强制改密) |
| customers | 客户表 |
| vehicles | 车辆表(status 只表达人工可用性,是否在租由订单推导) |
| orders | 订单表(含 platform / external_no / plate_number 车牌快照等) |
| order_fees | 订单费用明细表(0004 新增,按 fee_category 分类,随订单级联删除) |
| order_extensions | 订单续租记录表(0004 新增) |
| import_batches | 批量导入批次台账(0004 新增,支持整批撤销与溯源) |
| payments | 支付记录表 |
| order_sources | 订单来源表 |
| violations | 违章记录表 |
| blacklist | 黑名单表 |
| maintenance | 保养记录表 |
| insurance | 保险记录表 |
| inspections | 年检证表 |
| system_settings | 系统设置表 |
| operation_logs | 操作日志表 |
| login_attempts | 登录失败计数表(0008 新增,cron 清理 24 小时前的记录) |
- 同源部署:前后端同一个 Worker,不存在跨域问题;图片直接用后端返回的相对路径
- 数据库:D1 异步,必须用
src/db/helpers.ts的辅助函数,禁止直接c.env.DB.prepare() - 时区:一律北京时间。JS 侧用
src/lib/time.ts的now(),SQL 侧对应datetime('now','+8 hours'); 别再写toISOString()入库 - 上传:前端上传前压到 ≤500KB / 1600px / WebP(
frontend/src/utils/image.ts,PDF 与 GIF 跳过), 上传前校验统一走frontend/src/utils/upload.ts的validateUploadFile, 后端再卡 10MB、MIME 白名单与文件头魔数,存 R2;读取走带签名的/uploads/*(7 天有效) - SPA 刷新:
not_found_handling: single-page-application,直接刷新子路由不会 404 - 本地数据:
wrangler dev的本地 D1/R2 数据在.wrangler/state,删掉即重置; Node 后端方案的数据在.wrangler/state/dev-node(删data.sqlite重置) - 提交前验证:
npm run verify(typecheck + lint + test)。单测只覆盖src/lib的纯逻辑, 类型检查覆盖面更广但挡不住运行时问题,涉及金额、时区、导入映射的改动请补单测
详细的请求写法、业务规则、前端约定、设计系统规范见 AGENTS.md。
本项目采用 Apple 设计系统:唯一强调色 Apple Blue (#0071e3)、无可见边框、毛玻璃导航栏。
设计 token 定义在 frontend/src/style.css(--sk-* 变量),业务页面统一用 Element Plus 组件
(早期那套 Sk* 基础组件已移除),强调色固定为全局 Apple Blue #0071e3(--sk-focus-color),
不再支持在系统设置里自定义。完整规范见 AGENTS.md。
所有业务接口均需登录(Authorization: Bearer <token>),除登录接口与 /health 外。
标注「管理员」的接口额外经过 adminOnly。响应格式统一 { success, data?, message?, code? }。
POST /api/auth/login- 登录(返回 token 与must_change_password)GET /api/auth/me- 获取当前用户PUT /api/auth/password- 修改密码(会自增token_version,同时返回新 token)POST /api/auth/logout- 退出登录(只落审计日志,不吊销其它设备的 token)
GET/POST /api/users- 列表/创建GET/PUT/DELETE /api/users/:id- 详情/更新/删除PUT /api/users/:id/reset-password- 重置密码GET /api/users/options- 用户下拉选项(仅 id/name,员工可用,供司机指派)
GET /api/customers/regular- 常用客户GET/POST /api/customers- 列表/创建GET/PUT/DELETE /api/customers/:id- 详情/更新/删除PUT /api/customers/:id/regular- 设置常用客户
GET /api/vehicles/available- 可用车辆GET /api/vehicles/brands- 品牌列表GET /api/vehicles/options- 筛选选项GET/POST /api/vehicles- 列表/创建GET/PUT/DELETE /api/vehicles/:id- 详情/更新/删除
GET/POST /api/orders- 列表/创建GET /api/orders/stats- 订单统计GET /api/orders/:id- 订单详情PUT /api/orders/:id- 更新订单PUT /api/orders/:id/status- 取车/还车状态流转PUT /api/orders/:id/extend- 延期PUT /api/orders/:id/cancel- 取消PUT /api/orders/:id/drivers- 指派司机DELETE /api/orders/:id- 删除订单POST /api/orders/:id/payments- 添加支付记录
POST /api/orders/import/preview- 预检(解析 Excel、逐行校验、匹配客户/车辆,只读不写)POST /api/orders/import- 提交导入(生成import_batches记录)GET /api/orders/import/batches- 导入批次列表DELETE /api/orders/import/batches/:id- 撤销整批导入
GET /api/schedules/recent- 待办调度列表GET /api/schedules/gantt- 甘特图数据(vehicles为全部车辆、orders按车牌分组)- 可选
start_date/end_date(YYYY-MM-DD)指定窗口,缺省为往前 30 天至往后 60 天;窗口上限 366 天
- 可选
GET /api/violations/stats- 统计GET/POST /api/violations- 列表/创建GET/PUT/DELETE /api/violations/:id- 详情/更新/删除PUT /api/violations/:id/fee- 押金扣款PUT /api/violations/:id/handle- 标记处理
GET /api/blacklist/check- 检查GET/POST /api/blacklist- 列表/添加GET /api/blacklist/:id- 详情DELETE /api/blacklist/:id- 移除
GET/POST /api/order-sources- 列表/创建(创建需管理员)GET/PUT/DELETE /api/order-sources/:id- 详情/更新/删除(更新删除需管理员)
GET /api/maintenance/stats、GET/POST /api/maintenance、GET/PUT/DELETE /api/maintenance/:idGET /api/insurance/stats、GET/POST /api/insurance、GET/PUT/DELETE /api/insurance/:idGET /api/inspections/stats、GET/POST /api/inspections、PUT/DELETE /api/inspections/:vehicle_id
GET /api/dashboard/income- 收入报表GET/PUT /api/settings- 获取/更新设置(更新需管理员)GET /api/settings/:key- 单个设置项GET /api/logs- 日志列表(管理员)GET /api/logs/:id、GET /api/logs/action-types、GET /api/logs/entity-types、GET /api/logs/users(管理员)
表单字段名固定为 image,可选字段 name 用于语义化文件名。仅 insurance 支持 PDF,其余仅图片,后端上限 10MB。
上传接口返回的 url 也会被 signUploadUrls 中间件签名,可直接用于预览。
POST /api/upload- 其它文件POST /api/upload/inspection- 年检证POST /api/upload/insurance- 保险单(唯一允许 PDF)POST /api/upload/violation- 违章照片POST /api/upload/maintenance- 保养照片POST /api/upload/vehicle- 车辆照片POST /api/upload/customer- 客户证件
GET /health- 真探 D1 与 R2,返回{ status, checks: { database, storage }, timestamp },degraded时 503
历史变更已迁到 CHANGELOG.md。
{ "binding": "DB", "database_name": "zjzc", "database_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", // ← 必须替换成真实 UUID "migrations_dir": "migrations" }