feat: import CarLog v2.8 code + dev plan

把 CarLog v2.8 全套源码 + 配置导入到 i 仓库作为 baseline:
- server/src/ (13 个路由 + middleware + services + config)
- server/migrations/ (0001~0018 共 18 个迁移 + mysql)
- server/test/ (12 文件 101 测试)
- client/src/ (20 个 view + components + stores + api + composables)
- client/public/ + client/scripts/
- 全部配置文件 (.editorconfig, .eslintrc.json, .prettierrc.json, vitest.config.js, lighthouserc.json, .pa11yci.json, package.json, carlog-init.sql)
- .husky/pre-commit (git hooks)
- docs/install/ (宝塔部署文档)

不含:
- node_modules/ (本地 npm install)
- .env (敏感, 走 .env.example)
- *.zip / *.log / *.sqlite / .DS_Store

新增文档 docs/DEV-PLAN.md:
- Phase 1: 平台基座 (019 migration + 3 个 platform 路由 + 3 个 view)
- Phase 2: CarLog 子系统化 (后端 routes/ → subsystems/carlog/ + 前端 views/ → views/subsystems/carlog/ + 元数据驱动菜单)
- Phase 3: 验证 (测试 + E2E + DB 完整性)
- 交付清单 + commit 模板 + 给 Mavis review 的材料

后续 Trae 实施, 提交后我 code review + 跑测试。
This commit is contained in:
2026-06-20 22:30:19 +08:00
parent 77adc8e498
commit 65b0bb04f8
174 changed files with 31594 additions and 1 deletions
+155
View File
@@ -0,0 +1,155 @@
-- =============================================================================
-- 洗车记录系统 - Migration 0001: 基础表(Node.js / better-sqlite3 版)
-- =============================================================================
-- 基础 PRAGMA 由 server/src/db.js 统一设置(journal_mode=WAL / foreign_keys=ON / synchronous=NORMAL / busy_timeout=5000
-- -----------------------------------------------------------------------------
-- 1. chemicals - 药剂字典(Grocy 缓存层)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS chemicals (
grocy_product_id TEXT NOT NULL,
name TEXT NOT NULL,
category TEXT,
unit TEXT NOT NULL DEFAULT 'ml',
standard_dose REAL,
notes TEXT,
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
fetched_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (grocy_product_id)
);
CREATE INDEX IF NOT EXISTS idx_chemicals_category ON chemicals(category);
CREATE INDEX IF NOT EXISTS idx_chemicals_active ON chemicals(is_active);
CREATE INDEX IF NOT EXISTS idx_chemicals_fetched ON chemicals(fetched_at);
-- -----------------------------------------------------------------------------
-- 2. weather_snapshots - 天气快照
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS weather_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date TEXT NOT NULL,
city TEXT NOT NULL,
provider TEXT NOT NULL CHECK (provider IN ('qweather','openweathermap')),
temp_c REAL,
humidity INTEGER,
weather_desc TEXT,
weather_code TEXT,
wind_kph REAL,
precip_mm REAL,
raw_json TEXT,
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_weather_city_date ON weather_snapshots(city, snapshot_date);
CREATE INDEX IF NOT EXISTS idx_weather_date ON weather_snapshots(snapshot_date);
-- -----------------------------------------------------------------------------
-- 3. wash_records - 洗车记录
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS wash_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wash_date TEXT NOT NULL,
wash_type TEXT NOT NULL CHECK (wash_type IN ('quick','full','detail','other')),
weather_snapshot_id INTEGER,
location TEXT,
cost REAL NOT NULL DEFAULT 0,
duration_min INTEGER,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (weather_snapshot_id) REFERENCES weather_snapshots(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_wash_records_date ON wash_records(wash_date);
CREATE INDEX IF NOT EXISTS idx_wash_records_type ON wash_records(wash_type);
CREATE INDEX IF NOT EXISTS idx_wash_records_weather ON wash_records(weather_snapshot_id);
-- -----------------------------------------------------------------------------
-- 4. chemical_usage - 药剂消耗
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS chemical_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
usage_date TEXT NOT NULL,
chemical_id TEXT NOT NULL,
amount REAL NOT NULL,
wash_record_id INTEGER,
notes TEXT,
sync_status TEXT NOT NULL DEFAULT 'pending' CHECK (sync_status IN ('pending','synced','failed')),
sync_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (chemical_id) REFERENCES chemicals(grocy_product_id) ON DELETE RESTRICT,
FOREIGN KEY (wash_record_id) REFERENCES wash_records(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_usage_date ON chemical_usage(usage_date);
CREATE INDEX IF NOT EXISTS idx_usage_chemical ON chemical_usage(chemical_id);
CREATE INDEX IF NOT EXISTS idx_usage_wash ON chemical_usage(wash_record_id);
CREATE INDEX IF NOT EXISTS idx_usage_sync ON chemical_usage(sync_status);
-- -----------------------------------------------------------------------------
-- 5. settings - 运行时配置 KV
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS settings (
key TEXT NOT NULL PRIMARY KEY,
value TEXT,
is_secret INTEGER NOT NULL DEFAULT 0,
description TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- 预置 11 个 key
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
('db_path', NULL, 0, '数据库路径(SQLite 模式)'),
('app_city', NULL, 0, '所在城市(用于天气查询)'),
('app_timezone', 'Asia/Shanghai', 0, 'PHP 时区(保留兼容)'),
('grocy_url', NULL, 0, 'Grocy 实例 URL'),
('grocy_api_token', NULL, 1, 'Grocy REST API token'),
('weather_provider', 'qweather', 0, '天气提供方 qweather/openweathermap'),
('qweather_api_key', NULL, 1, '和风天气 API key'),
('qweather_api_host', 'api.qweather.com', 0, '和风 API host'),
('openweathermap_api_key', NULL, 1, 'OpenWeatherMap API key'),
('backup_keep_count', '10', 0, '本地备份保留份数'),
('backup_dir', 'storage/backups', 0, '备份输出目录');
-- -----------------------------------------------------------------------------
-- 6. 视图
-- -----------------------------------------------------------------------------
DROP VIEW IF EXISTS v_wash_monthly_count;
CREATE VIEW v_wash_monthly_count AS
SELECT
substr(wash_date, 1, 7) AS month,
COUNT(*) AS wash_count,
SUM(COALESCE(cost, 0)) AS total_cost
FROM wash_records
GROUP BY substr(wash_date, 1, 7)
ORDER BY month DESC;
DROP VIEW IF EXISTS v_chemical_monthly_usage;
CREATE VIEW v_chemical_monthly_usage AS
SELECT
substr(cu.usage_date, 1, 7) AS month,
c.grocy_product_id AS grocy_product_id,
c.name AS chemical_name,
c.unit AS unit,
SUM(cu.amount) AS total_amount,
COUNT(*) AS usage_count
FROM chemical_usage cu
JOIN chemicals c ON c.grocy_product_id = cu.chemical_id
GROUP BY substr(cu.usage_date, 1, 7), c.grocy_product_id
ORDER BY month DESC, total_amount DESC;
DROP VIEW IF EXISTS v_last_wash;
CREATE VIEW v_last_wash AS
SELECT
id AS wash_id,
wash_date,
wash_type,
CAST(julianday('now') - julianday(wash_date) AS INTEGER) AS days_since
FROM wash_records
ORDER BY wash_date DESC, id DESC
LIMIT 1;
+68
View File
@@ -0,0 +1,68 @@
-- =============================================================================
-- 洗车记录系统 - Migration 0002: 用户认证 + 防撞库
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. users - 登录账号
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user','admin')),
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
last_login_at TEXT,
last_login_ip TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
-- -----------------------------------------------------------------------------
-- 2. login_attempts - 登录尝试记录
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
attempted_at TEXT NOT NULL DEFAULT (datetime('now')),
ip_address TEXT NOT NULL,
username TEXT NOT NULL,
success INTEGER NOT NULL CHECK (success IN (0, 1)),
user_agent TEXT,
failure_reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_attempts_ip_time ON login_attempts(ip_address, attempted_at);
CREATE INDEX IF NOT EXISTS idx_attempts_user_time ON login_attempts(username, attempted_at);
CREATE INDEX IF NOT EXISTS idx_attempts_time ON login_attempts(attempted_at);
-- -----------------------------------------------------------------------------
-- 3. auth_locks - 锁状态
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS auth_locks (
lock_key TEXT PRIMARY KEY,
lock_type TEXT NOT NULL CHECK (lock_type IN ('ip','user')),
target TEXT NOT NULL,
locked_until TEXT NOT NULL,
reason TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_locks_until ON auth_locks(locked_until);
-- -----------------------------------------------------------------------------
-- 4. auth 设置 seed
-- -----------------------------------------------------------------------------
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
('session_lifetime_days', '30', 0, '登录 session 有效期(天)'),
('session_cookie_secure', 'auto', 0, 'Cookie secure 标志:true/false/auto'),
('login_max_failures_ip', '5', 0, '每 IP 允许的最大连续失败次数'),
('login_max_failures_user', '5', 0, '每用户名允许的最大连续失败次数'),
('login_lock_minutes_ip', '15', 0, 'IP 级别锁定时长(分钟)'),
('login_lock_minutes_user', '30', 0, '用户名级别锁定时长(分钟)'),
('login_global_max_failures', '10', 0, '触发全局 IP 封锁的失败次数'),
('login_global_lock_hours', '1', 0, '全局 IP 封锁时长(小时)'),
('login_attempts_retention_days', '30', 0, 'login_attempts 保留天数'),
('csrf_token_lifetime_hours', '12', 0, 'CSRF token 有效期(小时)'),
('bcrypt_cost', '12', 0, 'bcrypt cost factor');
+45
View File
@@ -0,0 +1,45 @@
-- =============================================================================
-- 洗车记录系统 - Migration 0003: 车辆管理
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. vehicles - 车辆字典
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS vehicles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
plate TEXT,
type TEXT NOT NULL DEFAULT 'car' CHECK (type IN ('car','suv','mpv','truck','other')),
color TEXT,
notes TEXT,
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_vehicles_active ON vehicles(is_active);
CREATE INDEX IF NOT EXISTS idx_vehicles_sort ON vehicles(sort_order);
-- -----------------------------------------------------------------------------
-- 2. wash_records 加 vehicle_id
-- -----------------------------------------------------------------------------
ALTER TABLE wash_records ADD COLUMN vehicle_id INTEGER REFERENCES vehicles(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_wash_records_vehicle ON wash_records(vehicle_id);
-- -----------------------------------------------------------------------------
-- 3. 视图:v_last_wash 加 vehicle_name
-- -----------------------------------------------------------------------------
DROP VIEW IF EXISTS v_last_wash;
CREATE VIEW v_last_wash AS
SELECT
w.id AS wash_id,
w.wash_date,
w.wash_type,
w.vehicle_id,
v.name AS vehicle_name,
CAST(julianday('now') - julianday(w.wash_date) AS INTEGER) AS days_since
FROM wash_records w
LEFT JOIN vehicles v ON v.id = w.vehicle_id
ORDER BY w.wash_date DESC, w.id DESC
LIMIT 1;
+27
View File
@@ -0,0 +1,27 @@
-- =============================================================================
-- 洗车记录系统 - Migration 0004: Grocy 主数据同步字段
-- =============================================================================
-- 1. chemicals 表加 Grocy 完整字段
ALTER TABLE chemicals ADD COLUMN description TEXT;
ALTER TABLE chemicals ADD COLUMN current_amount REAL NOT NULL DEFAULT 0;
ALTER TABLE chemicals ADD COLUMN current_value REAL NOT NULL DEFAULT 0;
ALTER TABLE chemicals ADD COLUMN min_stock_amount REAL NOT NULL DEFAULT 0;
ALTER TABLE chemicals ADD COLUMN best_before_date TEXT;
ALTER TABLE chemicals ADD COLUMN location TEXT;
ALTER TABLE chemicals ADD COLUMN product_group_id INTEGER;
ALTER TABLE chemicals ADD COLUMN qu_id INTEGER;
ALTER TABLE chemicals ADD COLUMN location_id INTEGER;
ALTER TABLE chemicals ADD COLUMN picture_file_name TEXT;
ALTER TABLE chemicals ADD COLUMN last_synced_at TEXT;
-- 2. 索引
CREATE INDEX IF NOT EXISTS idx_chem_amount ON chemicals(current_amount);
CREATE INDEX IF NOT EXISTS idx_chem_pg ON chemicals(product_group_id);
CREATE INDEX IF NOT EXISTS idx_chem_synced ON chemicals(last_synced_at);
-- 3. Grocy 设置 seed
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
('grocy_sync_batch', '50', 0, 'Grocy 扣减同步每批条数'),
('grocy_low_stock_pct', '20', 0, '低库存阈值(百分比,库存/min_stock_amount * 100 <= 该值时标红)'),
('grocy_pull_auto', '0', 0, 'Grocy 拉取模式:0=手动,1=启动时自动拉');
@@ -0,0 +1,39 @@
-- =============================================================================
-- 洗车记录系统 - Migration 0005: 资料来源 + 分类映射 + 用品详情
-- =============================================================================
-- 1. chemicals 表加资料来源 + 同步元数据
ALTER TABLE chemicals ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';
-- source: 'grocy' | 'manual' | 'seed'
ALTER TABLE chemicals ADD COLUMN grocy_last_pulled_at TEXT;
-- 2. 分类映射表(grocy_product_group_id → 真实名字)
CREATE TABLE IF NOT EXISTS category_mappings (
grocy_group_id INTEGER PRIMARY KEY,
display_name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- 3. chemical_inventory_log 进销存记录(本地系统用)
CREATE TABLE IF NOT EXISTS chemical_inventory_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chemical_id TEXT NOT NULL,
change_type TEXT NOT NULL CHECK (change_type IN ('purchase','consume','inventory','transfer','adjust')),
amount_delta REAL NOT NULL,
amount_after REAL,
source TEXT NOT NULL DEFAULT 'local',
-- 'local' = 本系统产生,'grocy' = 来自 Grocy
source_ref TEXT,
-- 外部引用(如 Grocy stock log id、wash_record_id 等)
note TEXT,
occurred_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (chemical_id) REFERENCES chemicals(grocy_product_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_invlog_chem ON chemical_inventory_log(chemical_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_invlog_type ON chemical_inventory_log(change_type);
-- 4. settings seed
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
('grocy_categories_json', '[]', 0, 'Grocy 分类 ID → 显示名映射(JSON: [{id, name}])');
@@ -0,0 +1,13 @@
-- =============================================================================
-- 洗车记录系统 - Migration 0006: 单位换算
-- =============================================================================
-- qu_factor: 1 个 user_input_unit 包含多少 stock_unit1=无换算)
-- 例如:阿达姆斯加仑装 stock_unit=毫升,qu_factor=37851 加仑 = 3785 毫升)
ALTER TABLE chemicals ADD COLUMN qu_factor REAL NOT NULL DEFAULT 1.0;
ALTER TABLE chemicals ADD COLUMN consume_unit_id INTEGER; -- Grocy qu_id_consume
ALTER TABLE chemicals ADD COLUMN consume_unit_name TEXT; -- 显示用
-- chemical_usage 加 stock_amount(最小单位,扣减 Grocy 用)
ALTER TABLE chemical_usage ADD COLUMN unit TEXT; -- 用户输入的单位
ALTER TABLE chemical_usage ADD COLUMN stock_amount REAL; -- 换算后 Grocy stock unit 量
ALTER TABLE chemical_usage ADD COLUMN consume_unit_id INTEGER;
+75
View File
@@ -0,0 +1,75 @@
-- 0007_vehicle_logs.sql — 保养 / 加油 / 充电三类用车记录
-- 三张表结构对称:(vehicle_id, log_date, odometer_km, location, total_cost, notes, created_at)
-- 业务差异字段单独存
CREATE TABLE IF NOT EXISTS maintenance_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vehicle_id INTEGER NOT NULL,
maint_date TEXT NOT NULL,
odometer_km INTEGER,
total_cost REAL NOT NULL DEFAULT 0,
shop TEXT,
items_json TEXT NOT NULL DEFAULT '[]', -- [{name, cost, interval_km}, ...]
next_due_date TEXT,
next_due_km INTEGER,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_maint_vehicle_date ON maintenance_records(vehicle_id, maint_date DESC);
CREATE INDEX IF NOT EXISTS idx_maint_date ON maintenance_records(maint_date DESC);
CREATE TABLE IF NOT EXISTS refuel_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vehicle_id INTEGER NOT NULL,
refuel_date TEXT NOT NULL,
odometer_km INTEGER,
liters REAL NOT NULL,
price_per_liter REAL,
total_cost REAL NOT NULL,
fuel_type TEXT, -- 92 / 95 / 98 / 0#柴油 / 自定义
is_full INTEGER NOT NULL DEFAULT 0, -- 是否加满(计算油耗需要)
station TEXT,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_refuel_vehicle_date ON refuel_records(vehicle_id, refuel_date DESC);
CREATE INDEX IF NOT EXISTS idx_refuel_date ON refuel_records(refuel_date DESC);
CREATE TABLE IF NOT EXISTS charging_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vehicle_id INTEGER NOT NULL,
charge_date TEXT NOT NULL,
odometer_km INTEGER,
kwh REAL NOT NULL,
price_per_kwh REAL,
total_cost REAL NOT NULL,
charge_type TEXT, -- slow (慢充/交流) / fast (快充/直流) / home (家充) / public (公共桩)
start_soc INTEGER, -- 起始电量 %
end_soc INTEGER, -- 结束电量 %
station TEXT,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_charging_vehicle_date ON charging_records(vehicle_id, charge_date DESC);
CREATE INDEX IF NOT EXISTS idx_charging_date ON charging_records(charge_date DESC);
-- 一张视图方便首页拿"最近 30 天每类最新 5 条"
DROP VIEW IF EXISTS v_recent_logs;
CREATE VIEW v_recent_logs AS
SELECT 'maintenance' AS log_type, id, vehicle_id, maint_date AS log_date,
total_cost, odometer_km, shop AS location
FROM maintenance_records
UNION ALL
SELECT 'refuel' AS log_type, id, vehicle_id, refuel_date AS log_date,
total_cost, odometer_km, station AS location
FROM refuel_records
UNION ALL
SELECT 'charging' AS log_type, id, vehicle_id, charge_date AS log_date,
total_cost, odometer_km, station AS location
FROM charging_records;
@@ -0,0 +1,27 @@
-- 0008_mileage_and_insurance.sql
-- 1. 混动车:保养记录加 EV 里程和 HEV 里程
ALTER TABLE maintenance_records ADD COLUMN ev_km INTEGER;
ALTER TABLE maintenance_records ADD COLUMN hev_km INTEGER;
-- 2. 保险记录(含附件)
CREATE TABLE IF NOT EXISTS insurance_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vehicle_id INTEGER NOT NULL,
insurance_type TEXT NOT NULL, -- 交强险 / 商业险 / 车损险 / 三责险 / 座位险 / 不计免赔 / 玻璃险 / 划痕险 / 自燃险 / 涉水险
company TEXT, -- 人保 / 平安 / 太保 / 中华 / ...
policy_no TEXT, -- 保单号
start_date TEXT NOT NULL, -- 生效日
end_date TEXT NOT NULL, -- 到期日
premium REAL, -- 保费
coverage_amount REAL, -- 保额(可选)
notes TEXT,
attachment_path TEXT, -- 保单图片/PDF 相对路径(uploads/insurance/xxx.pdf
attachment_name TEXT, -- 原文件名
attachment_mime TEXT, -- mime type
attachment_size INTEGER, -- 字节
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_insurance_vehicle ON insurance_records(vehicle_id);
CREATE INDEX IF NOT EXISTS idx_insurance_end_date ON insurance_records(end_date);
@@ -0,0 +1,5 @@
-- 0009_vehicle_powertrain.sql
-- 车辆动力类型:纯油 (ice) / 混动 (hev) / 纯电 (ev) / 增程 (erev)
-- 油耗只在 ice 上算;电耗只在 ev 上算;hev/erev 算不了(分不清油/电)
ALTER TABLE vehicles ADD COLUMN powertrain TEXT NOT NULL DEFAULT 'ice'
CHECK (powertrain IN ('ice', 'hev', 'ev', 'erev'));
+18
View File
@@ -0,0 +1,18 @@
-- 0010_operation_logs.sql — 操作日志(审计用)
-- 记录"会改变数据"的操作,重点是删除类(不可逆),也兼容未来扩展 create/update
CREATE TABLE IF NOT EXISTS operation_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
username TEXT, -- 冗余存一份:用户被删/改名后日志还能看懂
action TEXT NOT NULL, -- 'delete' | 'batch_delete' | 'create' | 'update' | ...
target_type TEXT NOT NULL, -- 'wash_record' | 'chemical' | ...
target_ids TEXT NOT NULL, -- JSON 数组(批量时是多个 id)
target_summary TEXT, -- 人类可读的摘要,例如 "洗车 2026-01-15 快速 ¥30"
detail_json TEXT, -- 任意 JSON,存删除前的快照等
ip TEXT,
user_agent TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_oplog_created ON operation_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_oplog_user_time ON operation_logs(username, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_oplog_action ON operation_logs(action, target_type, created_at DESC);
+19
View File
@@ -0,0 +1,19 @@
-- 0011_soft_delete.sql — 统一软删(is_deleted+ 操作日志完善
-- 所有数据表加 is_deleted 标志,DELETE 改为 UPDATE SET is_deleted=1
-- 恢复:UPDATE SET is_deleted=0(操作日志已存完整快照)
ALTER TABLE vehicles ADD COLUMN is_deleted INTEGER DEFAULT 0;
ALTER TABLE wash_records ADD COLUMN is_deleted INTEGER DEFAULT 0;
ALTER TABLE chemical_usage ADD COLUMN is_deleted INTEGER DEFAULT 0;
ALTER TABLE maintenance_records ADD COLUMN is_deleted INTEGER DEFAULT 0;
ALTER TABLE refuel_records ADD COLUMN is_deleted INTEGER DEFAULT 0;
ALTER TABLE charging_records ADD COLUMN is_deleted INTEGER DEFAULT 0;
ALTER TABLE insurance_records ADD COLUMN is_deleted INTEGER DEFAULT 0;
-- 索引加速
CREATE INDEX IF NOT EXISTS ix_vehicles_is_deleted ON vehicles(is_deleted);
CREATE INDEX IF NOT EXISTS ix_wash_records_is_deleted ON wash_records(is_deleted);
CREATE INDEX IF NOT EXISTS ix_maintenance_is_deleted ON maintenance_records(is_deleted);
CREATE INDEX IF NOT EXISTS ix_refuel_is_deleted ON refuel_records(is_deleted);
CREATE INDEX IF NOT EXISTS ix_charging_is_deleted ON charging_records(is_deleted);
CREATE INDEX IF NOT EXISTS ix_insurance_is_deleted ON insurance_records(is_deleted);
@@ -0,0 +1,5 @@
-- 0012_operation_logs_recovery.sql — 操作日志恢复支持
-- 1. operation_logs 表加 recovered_at 字段(恢复时间戳)
-- 2. 各数据表 is_deleted 默认值设为 0(已有列则跳过 ALTER TABLE 报错)
ALTER TABLE operation_logs ADD COLUMN recovered_at TEXT;
+26
View File
@@ -0,0 +1,26 @@
-- 0013_weather_wttr.sql — 天气表支持 wttr provider
-- 原 CHECK 只允许 qweather/openweathermap,扩展到包含 wttr
-- SQLite 无法直接 ALTER CHECK,需重建表
CREATE TABLE IF NOT EXISTS _weather_snapshots_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date TEXT NOT NULL,
city TEXT NOT NULL,
provider TEXT NOT NULL CHECK (provider IN ('wttr','qweather','openweathermap')),
temp_c REAL,
humidity INTEGER,
weather_desc TEXT,
weather_code TEXT,
wind_kph REAL,
precip_mm REAL,
raw_json TEXT,
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO _weather_snapshots_new
(id, snapshot_date, city, provider, temp_c, humidity, weather_desc, weather_code, wind_kph, precip_mm, raw_json, fetched_at)
SELECT id, snapshot_date, city, provider, temp_c, humidity, weather_desc, weather_code, wind_kph, precip_mm, raw_json, fetched_at
FROM weather_snapshots;
DROP TABLE weather_snapshots;
ALTER TABLE _weather_snapshots_new RENAME TO weather_snapshots;
CREATE UNIQUE INDEX IF NOT EXISTS uk_weather_city_date ON weather_snapshots(city, snapshot_date);
CREATE INDEX IF NOT EXISTS idx_weather_date ON weather_snapshots(snapshot_date);
+26
View File
@@ -0,0 +1,26 @@
-- =============================================================================
-- Migration 0014: Grocy 鉴权改造 + 同步日志表 + 天气默认城市
-- =============================================================================
-- 1. settings 表:新增 grocy_username / grocy_password
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
('grocy_username', '', 1, 'Grocy 用户名(session cookie 鉴权)'),
('grocy_password', '', 1, 'Grocy 密码(session cookie 鉴权)');
-- 2. 新增默认城市设置
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
('app_city_default', '', 0, '天气默认城市(永久生效)');
-- 3. Grocy 同步日志表
CREATE TABLE IF NOT EXISTS grocy_sync_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL, -- 'pull_products' | 'sync_usage'
status TEXT NOT NULL, -- 'success' | 'failed' | 'partial'
ok_count INTEGER NOT NULL DEFAULT 0,
fail_count INTEGER NOT NULL DEFAULT 0,
detail TEXT, -- JSON 详情
started_at TEXT NOT NULL DEFAULT (datetime('now')),
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_grocy_sync_logs_action ON grocy_sync_logs(action);
CREATE INDEX IF NOT EXISTS idx_grocy_sync_logs_started ON grocy_sync_logs(started_at DESC);
+19
View File
@@ -0,0 +1,19 @@
-- 0015_wash_photos.sql (MySQL) — 洗车对比照(before / after / detail
CREATE TABLE IF NOT EXISTS wash_photos (
id INT AUTO_INCREMENT PRIMARY KEY,
wash_id INT NOT NULL,
photo_type VARCHAR(20) NOT NULL DEFAULT 'detail', -- before / after / detail / scene
file_path VARCHAR(500) NOT NULL,
file_name VARCHAR(255) NOT NULL,
mime_type VARCHAR(50) DEFAULT NULL,
file_size INT DEFAULT NULL,
width INT DEFAULT NULL,
height INT DEFAULT NULL,
caption VARCHAR(255) DEFAULT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
INDEX idx_wash_photos_wash (wash_id, is_deleted),
INDEX idx_wash_photos_type (photo_type),
INDEX idx_wash_photos_created (created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,34 @@
-- 0016_vehicle_current_km.sql — 车辆当前里程(手动校准)
-- 真实里程 = MAX(current_km, MAX(odometer_km) FROM 各日志表)
-- 用户可以手动覆盖 current_km(比如仪表盘数与日志对不上时)
ALTER TABLE vehicles
ADD COLUMN current_km INT DEFAULT NULL COMMENT '手动校准的当前里程,NULL 时按各日志表 MAX 算';
-- 保险提示阈值(可被 settings 覆盖)
CREATE TABLE IF NOT EXISTS notification_prefs (
key_name VARCHAR(50) NOT NULL PRIMARY KEY,
days INT NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO notification_prefs (key_name, days, enabled) VALUES
('refuel_remind_days', 30, 1),
('maintenance_remind_days', 180, 1),
('wash_remind_days', 14, 1)
ON DUPLICATE KEY UPDATE updated_at = NOW();
-- 站内通知表(OCR / 同步 / 备份结果持久化)
CREATE TABLE IF NOT EXISTS notifications (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT DEFAULT NULL,
type VARCHAR(30) NOT NULL, -- ocr_done / sync_done / backup_done / system
title VARCHAR(200) NOT NULL,
body TEXT DEFAULT NULL,
link VARCHAR(500) DEFAULT NULL,
severity VARCHAR(20) NOT NULL DEFAULT 'info', -- info / warn / error / success
is_read TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_notif_user_unread (user_id, is_read, created_at DESC),
INDEX idx_notif_created (created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+19
View File
@@ -0,0 +1,19 @@
-- 0017_tags.sql — 标签系统
CREATE TABLE IF NOT EXISTS tags (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
color VARCHAR(20) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_tag_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS record_tags (
id INT AUTO_INCREMENT PRIMARY KEY,
record_type VARCHAR(20) NOT NULL, -- wash / refuel / charge / maintenance / insurance
record_id INT NOT NULL,
tag_id INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_record_tag (record_type, record_id, tag_id),
INDEX idx_record (record_type, record_id),
INDEX idx_tag (tag_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+39
View File
@@ -0,0 +1,39 @@
-- 0018_achievements.sql — 成就系统
CREATE TABLE IF NOT EXISTS achievements (
id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(50) NOT NULL, -- 成就 code,如 'wash_streak_30'
name VARCHAR(100) NOT NULL,
description VARCHAR(255) DEFAULT NULL,
icon VARCHAR(20) DEFAULT NULL, -- emoji
threshold INT NOT NULL DEFAULT 1, -- 触发条件
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_ach_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user_achievements (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
achievement_id INT NOT NULL,
progress INT NOT NULL DEFAULT 0,
unlocked_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_user_ach (user_id, achievement_id),
INDEX idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO achievements (code, name, description, icon, threshold) VALUES
('wash_first', '洗车新手', '完成第一笔洗车记录', '🧽', 1),
('wash_10', '勤洗车友', '累计洗车 10 次', '🚿', 10),
('wash_50', '洗车达人', '累计洗车 50 次', '🛁', 50),
('wash_100', '洗车狂魔', '累计洗车 100 次', '🏆', 100),
('wash_streak_7', '一周一洗', '连续 7 天至少洗 1 次', '📅', 7),
('wash_streak_30', '月度好习惯', '连续 30 天至少洗 1 次', '🌟', 30),
('refuel_10', '小有车生活', '累计加油 10 次', '', 10),
('refuel_50', '老司机', '累计加油 50 次', '🚗', 50),
('mileage_10000', '万里征程', '累计行驶突破 10000 公里', '🛣️', 10000),
('mileage_100000', '十万俱乐部', '累计行驶突破 100000 公里', '🏅', 100000),
('maintain_first', '爱车初保养', '完成第一笔保养记录', '🔧', 1),
('maintain_5', '按时保养', '累计保养 5 次', '⚙️', 5),
('cost_track_30d', '记账坚持者', '连续 30 天有记录', '📊', 30),
('insure_first', '保险达人', '记录第一张保单', '🛡️', 1)
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), icon = VALUES(icon), threshold = VALUES(threshold);
+148
View File
@@ -0,0 +1,148 @@
-- =============================================================================
-- 洗车记录系统 - Migration 0001: 基础表 (MySQL 8.x)
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. chemicals - 药剂字典(Grocy 缓存层)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS chemicals (
grocy_product_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
category VARCHAR(255) DEFAULT NULL,
unit VARCHAR(50) NOT NULL DEFAULT 'ml',
standard_dose DOUBLE DEFAULT NULL,
notes TEXT DEFAULT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
fetched_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (grocy_product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_chemicals_category ON chemicals(category);
CREATE INDEX idx_chemicals_active ON chemicals(is_active);
CREATE INDEX idx_chemicals_fetched ON chemicals(fetched_at);
-- -----------------------------------------------------------------------------
-- 2. weather_snapshots - 天气快照
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS weather_snapshots (
id INT AUTO_INCREMENT PRIMARY KEY,
snapshot_date VARCHAR(10) NOT NULL,
city VARCHAR(100) NOT NULL,
provider VARCHAR(50) NOT NULL,
temp_c DOUBLE DEFAULT NULL,
humidity INT DEFAULT NULL,
weather_desc VARCHAR(255) DEFAULT NULL,
weather_code VARCHAR(20) DEFAULT NULL,
wind_kph DOUBLE DEFAULT NULL,
precip_mm DOUBLE DEFAULT NULL,
raw_json TEXT DEFAULT NULL,
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE UNIQUE INDEX uk_weather_city_date ON weather_snapshots(city, snapshot_date);
CREATE INDEX idx_weather_date ON weather_snapshots(snapshot_date);
-- -----------------------------------------------------------------------------
-- 3. wash_records - 洗车记录
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS wash_records (
id INT AUTO_INCREMENT PRIMARY KEY,
wash_date VARCHAR(10) NOT NULL,
wash_type VARCHAR(20) NOT NULL,
weather_snapshot_id INT DEFAULT NULL,
location VARCHAR(255) DEFAULT NULL,
cost DOUBLE NOT NULL DEFAULT 0,
duration_min INT DEFAULT NULL,
notes TEXT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_wash_type CHECK (wash_type IN ('quick','full','detail','other')),
CONSTRAINT fk_wash_weather FOREIGN KEY (weather_snapshot_id) REFERENCES weather_snapshots(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_wash_records_date ON wash_records(wash_date);
CREATE INDEX idx_wash_records_type ON wash_records(wash_type);
CREATE INDEX idx_wash_records_weather ON wash_records(weather_snapshot_id);
-- -----------------------------------------------------------------------------
-- 4. chemical_usage - 药剂消耗
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS chemical_usage (
id INT AUTO_INCREMENT PRIMARY KEY,
usage_date VARCHAR(10) NOT NULL,
chemical_id VARCHAR(255) NOT NULL,
amount DOUBLE NOT NULL,
wash_record_id INT DEFAULT NULL,
notes TEXT DEFAULT NULL,
sync_status VARCHAR(20) NOT NULL DEFAULT 'pending',
sync_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_sync_status CHECK (sync_status IN ('pending','synced','failed')),
CONSTRAINT fk_usage_chemical FOREIGN KEY (chemical_id) REFERENCES chemicals(grocy_product_id) ON DELETE RESTRICT,
CONSTRAINT fk_usage_wash FOREIGN KEY (wash_record_id) REFERENCES wash_records(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_usage_date ON chemical_usage(usage_date);
CREATE INDEX idx_usage_chemical ON chemical_usage(chemical_id);
CREATE INDEX idx_usage_wash ON chemical_usage(wash_record_id);
CREATE INDEX idx_usage_sync ON chemical_usage(sync_status);
-- -----------------------------------------------------------------------------
-- 5. settings - 运行时配置 KV
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS settings (
`key` VARCHAR(100) NOT NULL PRIMARY KEY,
value TEXT DEFAULT NULL,
is_secret TINYINT(1) NOT NULL DEFAULT 0,
description TEXT DEFAULT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
('app_city', NULL, 0, '所在城市(用于天气查询)'),
('app_timezone', 'Asia/Shanghai', 0, '时区'),
('grocy_url', NULL, 0, 'Grocy 实例 URL'),
('grocy_api_token', NULL, 1, 'Grocy REST API token'),
('backup_keep_count', '10', 0, '本地备份保留份数'),
('backup_dir', 'storage/backups', 0, '备份输出目录');
-- -----------------------------------------------------------------------------
-- 6. views
-- -----------------------------------------------------------------------------
DROP VIEW IF EXISTS v_wash_monthly_count;
CREATE VIEW v_wash_monthly_count AS
SELECT
SUBSTRING(wash_date, 1, 7) AS month,
COUNT(*) AS wash_count,
SUM(COALESCE(cost, 0)) AS total_cost
FROM wash_records
GROUP BY SUBSTRING(wash_date, 1, 7)
ORDER BY month DESC;
DROP VIEW IF EXISTS v_chemical_monthly_usage;
CREATE VIEW v_chemical_monthly_usage AS
SELECT
SUBSTRING(cu.usage_date, 1, 7) AS month,
c.grocy_product_id AS grocy_product_id,
c.name AS chemical_name,
c.unit AS unit,
SUM(cu.amount) AS total_amount,
COUNT(*) AS usage_count
FROM chemical_usage cu
JOIN chemicals c ON c.grocy_product_id = cu.chemical_id
GROUP BY SUBSTRING(cu.usage_date, 1, 7), c.grocy_product_id
ORDER BY month DESC, total_amount DESC;
DROP VIEW IF EXISTS v_last_wash;
CREATE VIEW v_last_wash AS
SELECT
id AS wash_id,
wash_date,
wash_type,
DATEDIFF(NOW(), STR_TO_DATE(wash_date, '%Y-%m-%d')) AS days_since
FROM wash_records
ORDER BY wash_date DESC, id DESC
LIMIT 1;
+57
View File
@@ -0,0 +1,57 @@
-- 0002_auth.sql - 用户认证 + 防撞库 (MySQL)
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'user',
is_active TINYINT(1) NOT NULL DEFAULT 1,
last_login_at DATETIME DEFAULT NULL,
last_login_ip VARCHAR(45) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_role CHECK (role IN ('user','admin'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_users_active ON users(is_active);
CREATE TABLE IF NOT EXISTS login_attempts (
id INT AUTO_INCREMENT PRIMARY KEY,
attempted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ip_address VARCHAR(45) NOT NULL,
username VARCHAR(50) NOT NULL,
success TINYINT(1) NOT NULL,
user_agent VARCHAR(500) DEFAULT NULL,
failure_reason VARCHAR(100) DEFAULT NULL,
CONSTRAINT chk_success CHECK (success IN (0, 1))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_attempts_ip_time ON login_attempts(ip_address, attempted_at);
CREATE INDEX idx_attempts_user_time ON login_attempts(username, attempted_at);
CREATE INDEX idx_attempts_time ON login_attempts(attempted_at);
CREATE TABLE IF NOT EXISTS auth_locks (
lock_key VARCHAR(100) PRIMARY KEY,
lock_type VARCHAR(10) NOT NULL,
target VARCHAR(50) NOT NULL,
locked_until DATETIME NOT NULL,
reason VARCHAR(255) DEFAULT NULL,
attempts INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_lock_type CHECK (lock_type IN ('ip','user'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_locks_until ON auth_locks(locked_until);
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
('session_lifetime_days', '30', 0, '登录 session 有效期(天)'),
('session_cookie_secure', 'auto', 0, 'Cookie secure 标志:true/false/auto'),
('login_max_failures_ip', '5', 0, '每 IP 允许的最大连续失败次数'),
('login_max_failures_user', '5', 0, '每用户名允许的最大连续失败次数'),
('login_lock_minutes_ip', '15', 0, 'IP 级别锁定时长(分钟)'),
('login_lock_minutes_user', '30', 0, '用户名级别锁定时长(分钟)'),
('login_global_max_failures', '10', 0, '触发全局 IP 封锁的失败次数'),
('login_global_lock_hours', '1', 0, '全局 IP 封锁时长(小时)'),
('login_attempts_retention_days', '30', 0, 'login_attempts 保留天数'),
('csrf_token_lifetime_hours', '12', 0, 'CSRF token 有效期(小时)'),
('bcrypt_cost', '12', 0, 'bcrypt cost factor');
+35
View File
@@ -0,0 +1,35 @@
-- 0003_vehicles.sql - 车辆管理 (MySQL)
CREATE TABLE IF NOT EXISTS vehicles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
plate VARCHAR(20) DEFAULT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'car',
color VARCHAR(30) DEFAULT NULL,
notes TEXT DEFAULT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_vehicle_type CHECK (type IN ('car','suv','mpv','truck','other'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_vehicles_active ON vehicles(is_active);
CREATE INDEX idx_vehicles_sort ON vehicles(sort_order);
ALTER TABLE wash_records ADD COLUMN vehicle_id INT DEFAULT NULL;
CREATE INDEX idx_wash_records_vehicle ON wash_records(vehicle_id);
DROP VIEW IF EXISTS v_last_wash;
CREATE VIEW v_last_wash AS
SELECT
w.id AS wash_id,
w.wash_date,
w.wash_type,
w.vehicle_id,
v.name AS vehicle_name,
DATEDIFF(NOW(), STR_TO_DATE(w.wash_date, '%Y-%m-%d')) AS days_since
FROM wash_records w
LEFT JOIN vehicles v ON v.id = w.vehicle_id
ORDER BY w.wash_date DESC, w.id DESC
LIMIT 1;
@@ -0,0 +1,22 @@
-- 0004_grocy_full.sql - Grocy 主数据同步字段 (MySQL)
ALTER TABLE chemicals
ADD COLUMN description TEXT DEFAULT NULL,
ADD COLUMN current_amount DOUBLE NOT NULL DEFAULT 0,
ADD COLUMN current_value DOUBLE NOT NULL DEFAULT 0,
ADD COLUMN min_stock_amount DOUBLE NOT NULL DEFAULT 0,
ADD COLUMN best_before_date VARCHAR(20) DEFAULT NULL,
ADD COLUMN location VARCHAR(255) DEFAULT NULL,
ADD COLUMN product_group_id INT DEFAULT NULL,
ADD COLUMN qu_id INT DEFAULT NULL,
ADD COLUMN location_id INT DEFAULT NULL,
ADD COLUMN picture_file_name VARCHAR(255) DEFAULT NULL,
ADD COLUMN last_synced_at DATETIME DEFAULT NULL;
CREATE INDEX idx_chem_amount ON chemicals(current_amount);
CREATE INDEX idx_chem_pg ON chemicals(product_group_id);
CREATE INDEX idx_chem_synced ON chemicals(last_synced_at);
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
('grocy_sync_batch', '50', 0, 'Grocy 扣减同步每批条数'),
('grocy_low_stock_pct', '20', 0, '低库存阈值(百分比)'),
('grocy_pull_auto', '0', 0, 'Grocy 拉取模式:0=手动,1=启动时自动拉');
@@ -0,0 +1,31 @@
-- 0005_inventory_detail.sql (MySQL)
ALTER TABLE chemicals
ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'manual',
ADD COLUMN grocy_last_pulled_at DATETIME DEFAULT NULL;
CREATE TABLE IF NOT EXISTS category_mappings (
grocy_group_id INT PRIMARY KEY,
display_name VARCHAR(100) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS chemical_inventory_log (
id INT AUTO_INCREMENT PRIMARY KEY,
chemical_id VARCHAR(255) NOT NULL,
change_type VARCHAR(20) NOT NULL,
amount_delta DOUBLE NOT NULL,
amount_after DOUBLE DEFAULT NULL,
source VARCHAR(20) NOT NULL DEFAULT 'local',
source_ref VARCHAR(255) DEFAULT NULL,
note TEXT DEFAULT NULL,
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_change_type CHECK (change_type IN ('purchase','consume','inventory','transfer','adjust'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_invlog_chem ON chemical_inventory_log(chemical_id, occurred_at DESC);
CREATE INDEX idx_invlog_type ON chemical_inventory_log(change_type);
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
('grocy_categories_json', '[]', 0, 'Grocy 分类映射 JSON');
@@ -0,0 +1,10 @@
-- 0006_unit_conversion.sql (MySQL)
ALTER TABLE chemicals
ADD COLUMN qu_factor DOUBLE NOT NULL DEFAULT 1.0,
ADD COLUMN consume_unit_id INT DEFAULT NULL,
ADD COLUMN consume_unit_name VARCHAR(100) DEFAULT NULL;
ALTER TABLE chemical_usage
ADD COLUMN unit VARCHAR(50) DEFAULT NULL,
ADD COLUMN stock_amount DOUBLE DEFAULT NULL,
ADD COLUMN consume_unit_id INT DEFAULT NULL;
@@ -0,0 +1,72 @@
-- 0007_vehicle_logs.sql (MySQL)
CREATE TABLE IF NOT EXISTS maintenance_records (
id INT AUTO_INCREMENT PRIMARY KEY,
vehicle_id INT NOT NULL,
maint_date VARCHAR(10) NOT NULL,
odometer_km INT DEFAULT NULL,
total_cost DOUBLE NOT NULL DEFAULT 0,
shop VARCHAR(255) DEFAULT NULL,
items_json JSON NOT NULL DEFAULT ('[]'),
next_due_date VARCHAR(10) DEFAULT NULL,
next_due_km INT DEFAULT NULL,
notes TEXT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_maint_vehicle_date ON maintenance_records(vehicle_id, maint_date DESC);
CREATE INDEX idx_maint_date ON maintenance_records(maint_date DESC);
CREATE TABLE IF NOT EXISTS refuel_records (
id INT AUTO_INCREMENT PRIMARY KEY,
vehicle_id INT NOT NULL,
refuel_date VARCHAR(10) NOT NULL,
odometer_km INT DEFAULT NULL,
liters DOUBLE NOT NULL,
price_per_liter DOUBLE DEFAULT NULL,
total_cost DOUBLE NOT NULL,
fuel_type VARCHAR(20) DEFAULT NULL,
is_full TINYINT(1) NOT NULL DEFAULT 0,
station VARCHAR(255) DEFAULT NULL,
notes TEXT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_refuel_vehicle_date ON refuel_records(vehicle_id, refuel_date DESC);
CREATE INDEX idx_refuel_date ON refuel_records(refuel_date DESC);
CREATE TABLE IF NOT EXISTS charging_records (
id INT AUTO_INCREMENT PRIMARY KEY,
vehicle_id INT NOT NULL,
charge_date VARCHAR(10) NOT NULL,
odometer_km INT DEFAULT NULL,
kwh DOUBLE NOT NULL,
price_per_kwh DOUBLE DEFAULT NULL,
total_cost DOUBLE NOT NULL,
charge_type VARCHAR(20) DEFAULT NULL,
start_soc INT DEFAULT NULL,
end_soc INT DEFAULT NULL,
station VARCHAR(255) DEFAULT NULL,
notes TEXT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_charging_vehicle_date ON charging_records(vehicle_id, charge_date DESC);
CREATE INDEX idx_charging_date ON charging_records(charge_date DESC);
DROP VIEW IF EXISTS v_recent_logs;
CREATE VIEW v_recent_logs AS
SELECT 'maintenance' AS log_type, id, vehicle_id, maint_date AS log_date,
total_cost, odometer_km, shop AS location
FROM maintenance_records
UNION ALL
SELECT 'refuel' AS log_type, id, vehicle_id, refuel_date AS log_date,
total_cost, odometer_km, station AS location
FROM refuel_records
UNION ALL
SELECT 'charging' AS log_type, id, vehicle_id, charge_date AS log_date,
total_cost, odometer_km, station AS location
FROM charging_records;
@@ -0,0 +1,27 @@
-- 0008_mileage_and_insurance.sql (MySQL)
ALTER TABLE maintenance_records
ADD COLUMN ev_km INT DEFAULT NULL,
ADD COLUMN hev_km INT DEFAULT NULL;
CREATE TABLE IF NOT EXISTS insurance_records (
id INT AUTO_INCREMENT PRIMARY KEY,
vehicle_id INT NOT NULL,
insurance_type VARCHAR(50) NOT NULL,
company VARCHAR(100) DEFAULT NULL,
policy_no VARCHAR(100) DEFAULT NULL,
start_date VARCHAR(10) NOT NULL,
end_date VARCHAR(10) NOT NULL,
premium DOUBLE DEFAULT NULL,
coverage_amount DOUBLE DEFAULT NULL,
notes TEXT DEFAULT NULL,
attachment_path VARCHAR(500) DEFAULT NULL,
attachment_name VARCHAR(255) DEFAULT NULL,
attachment_mime VARCHAR(100) DEFAULT NULL,
attachment_size INT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_insurance_vehicle ON insurance_records(vehicle_id);
CREATE INDEX idx_insurance_end_date ON insurance_records(end_date);
@@ -0,0 +1,3 @@
-- 0009_vehicle_powertrain.sql (MySQL)
ALTER TABLE vehicles
ADD COLUMN powertrain VARCHAR(10) NOT NULL DEFAULT 'ice';
@@ -0,0 +1,19 @@
-- 0010_operation_logs.sql (MySQL)
CREATE TABLE IF NOT EXISTS operation_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT DEFAULT NULL,
username VARCHAR(50) DEFAULT NULL,
action VARCHAR(50) NOT NULL,
target_type VARCHAR(50) NOT NULL,
target_ids TEXT NOT NULL,
target_summary TEXT DEFAULT NULL,
detail_json TEXT DEFAULT NULL,
ip VARCHAR(45) DEFAULT NULL,
user_agent VARCHAR(500) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_oplog_created ON operation_logs(created_at DESC);
CREATE INDEX idx_oplog_user_time ON operation_logs(username, created_at DESC);
CREATE INDEX idx_oplog_action ON operation_logs(action, target_type, created_at DESC);
@@ -0,0 +1,15 @@
-- 0011_soft_delete.sql (MySQL)
ALTER TABLE vehicles ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE wash_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE chemical_usage ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE maintenance_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE refuel_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE charging_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE insurance_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0;
CREATE INDEX ix_vehicles_is_deleted ON vehicles(is_deleted);
CREATE INDEX ix_wash_records_is_deleted ON wash_records(is_deleted);
CREATE INDEX ix_maintenance_is_deleted ON maintenance_records(is_deleted);
CREATE INDEX ix_refuel_is_deleted ON refuel_records(is_deleted);
CREATE INDEX ix_charging_is_deleted ON charging_records(is_deleted);
CREATE INDEX ix_insurance_is_deleted ON insurance_records(is_deleted);
@@ -0,0 +1,2 @@
-- 0012_operation_logs_recovery.sql (MySQL)
ALTER TABLE operation_logs ADD COLUMN recovered_at DATETIME DEFAULT NULL;
@@ -0,0 +1,3 @@
-- 0013_weather_wttr.sql (MySQL)
-- 删除旧 CHECK 并重建(MySQL 允许 ALTER TABLE 改 CHECK,但为保险用 ALTER COLUMN
ALTER TABLE weather_snapshots MODIFY COLUMN provider VARCHAR(50) NOT NULL;
@@ -0,0 +1,20 @@
-- 0014_grocy_auth.sql (MySQL)
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
('grocy_username', '', 1, 'Grocy 用户名(session cookie 鉴权)'),
('grocy_password', '', 1, 'Grocy 密码(session cookie 鉴权)'),
('app_city_default', '', 0, '天气默认城市(永久生效)');
CREATE TABLE IF NOT EXISTS grocy_sync_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
action VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL,
ok_count INT NOT NULL DEFAULT 0,
fail_count INT NOT NULL DEFAULT 0,
detail TEXT DEFAULT NULL,
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at DATETIME DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_grocy_sync_logs_action ON grocy_sync_logs(action);
CREATE INDEX idx_grocy_sync_logs_started ON grocy_sync_logs(started_at DESC);
@@ -0,0 +1,19 @@
-- 0015_wash_photos.sql (MySQL) — 洗车对比照(before / after / detail
CREATE TABLE IF NOT EXISTS wash_photos (
id INT AUTO_INCREMENT PRIMARY KEY,
wash_id INT NOT NULL,
photo_type VARCHAR(20) NOT NULL DEFAULT 'detail', -- before / after / detail / scene
file_path VARCHAR(500) NOT NULL,
file_name VARCHAR(255) NOT NULL,
mime_type VARCHAR(50) DEFAULT NULL,
file_size INT DEFAULT NULL,
width INT DEFAULT NULL,
height INT DEFAULT NULL,
caption VARCHAR(255) DEFAULT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
INDEX idx_wash_photos_wash (wash_id, is_deleted),
INDEX idx_wash_photos_type (photo_type),
INDEX idx_wash_photos_created (created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,30 @@
-- 0016_vehicle_current_km.sql (MySQL)
ALTER TABLE vehicles
ADD COLUMN current_km INT DEFAULT NULL;
CREATE TABLE IF NOT EXISTS notification_prefs (
key_name VARCHAR(50) NOT NULL PRIMARY KEY,
days INT NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO notification_prefs (key_name, days, enabled) VALUES
('refuel_remind_days', 30, 1),
('maintenance_remind_days', 180, 1),
('wash_remind_days', 14, 1)
ON DUPLICATE KEY UPDATE updated_at = NOW();
CREATE TABLE IF NOT EXISTS notifications (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT DEFAULT NULL,
type VARCHAR(30) NOT NULL,
title VARCHAR(200) NOT NULL,
body TEXT DEFAULT NULL,
link VARCHAR(500) DEFAULT NULL,
severity VARCHAR(20) NOT NULL DEFAULT 'info',
is_read TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_notif_user_unread (user_id, is_read, created_at DESC),
INDEX idx_notif_created (created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+19
View File
@@ -0,0 +1,19 @@
-- 0017_tags.sql (MySQL)
CREATE TABLE IF NOT EXISTS tags (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
color VARCHAR(20) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_tag_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS record_tags (
id INT AUTO_INCREMENT PRIMARY KEY,
record_type VARCHAR(20) NOT NULL,
record_id INT NOT NULL,
tag_id INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_record_tag (record_type, record_id, tag_id),
INDEX idx_record (record_type, record_id),
INDEX idx_tag (tag_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,39 @@
-- 0018_achievements.sql (MySQL)
CREATE TABLE IF NOT EXISTS achievements (
id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
description VARCHAR(255) DEFAULT NULL,
icon VARCHAR(20) DEFAULT NULL,
threshold INT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_ach_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user_achievements (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
achievement_id INT NOT NULL,
progress INT NOT NULL DEFAULT 0,
unlocked_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_user_ach (user_id, achievement_id),
INDEX idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO achievements (code, name, description, icon, threshold) VALUES
('wash_first', '洗车新手', '完成第一笔洗车记录', '🧽', 1),
('wash_10', '勤洗车友', '累计洗车 10 次', '🚿', 10),
('wash_50', '洗车达人', '累计洗车 50 次', '🛁', 50),
('wash_100', '洗车狂魔', '累计洗车 100 次', '🏆', 100),
('wash_streak_7', '一周一洗', '连续 7 天至少洗 1 次', '📅', 7),
('wash_streak_30', '月度好习惯', '连续 30 天至少洗 1 次', '🌟', 30),
('refuel_10', '小有车生活', '累计加油 10 次', '', 10),
('refuel_50', '老司机', '累计加油 50 次', '🚗', 50),
('mileage_10000', '万里征程', '累计行驶突破 10000 公里', '🛣️', 10000),
('mileage_100000', '十万俱乐部', '累计行驶突破 100000 公里', '🏅', 100000),
('maintain_first', '爱车初保养', '完成第一笔保养记录', '🔧', 1),
('maintain_5', '按时保养', '累计保养 5 次', '⚙️', 5),
('cost_track_30d', '记账坚持者', '连续 30 天有记录', '📊', 30),
('insure_first', '保险达人', '记录第一张保单', '🛡️', 1)
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), icon = VALUES(icon), threshold = VALUES(threshold);
+3058
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "carwash-server",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/bin/serve.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.3.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"exceljs": "^4.4.0",
"express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"express-session": "^1.18.0",
"multer": "^2.2.0",
"mysql2": "^3.22.5",
"pdfkit": "^0.19.1",
"swagger-jsdoc": "^6.3.0",
"swagger-ui-express": "^5.0.1",
"undici": "^6.27.0"
},
"engines": {
"node": ">=20"
}
}
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env node
// server/src/bin/backup.js — 备份 SQLite + exports
import { cli } from '../services/backup.js';
cli(process.argv.slice(2));
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env node
// server/src/bin/export.js — 导出 CSV
import { cli } from '../services/exporter.js';
await cli(process.argv.slice(2));
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env node
// server/src/bin/grocy-refresh-products.js — 从 Grocy 拉产品主数据
import { cli } from '../services/grocyProducts.js';
import { loadConfig } from '../config.js';
const cfg = await loadConfig();
await cli(process.argv.slice(2), cfg);
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env node
// server/src/bin/grocy-sync.js — 同步 chemical_usage 到 Grocy
import { cli } from '../services/grocy.js';
import { loadConfig } from '../config.js';
const cfg = await loadConfig();
await cli(process.argv.slice(2), cfg);
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env node
// server/src/bin/migrate.js — 运行所有 SQL 迁移;首次启动时创建默认管理员
import { db, migrate } from '../db.js';
import { ensureDefaultUser } from '../services/auth.js';
const VERBOSE = process.argv.includes('-v') || process.argv.includes('--verbose');
console.log('Running migrations:');
const r = migrate({ verbose: VERBOSE });
console.log(`\nApplied: ${r.applied} new, total files: ${r.total}`);
const u = ensureDefaultUser();
if (u.created) {
console.log(`\n✓ 已创建默认管理员账号`);
console.log(` 用户名: admin`);
console.log(` 密码: carwash2026`);
console.log(` ⚠ 首次登录后请到「设置 → 账户」修改密码!`);
} else {
console.log(`\n✓ 默认管理员已存在 (username=${u.username})`);
}
console.log(`\n数据库: ${db().name}`);
const tables = db().prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all();
console.log(`表 (${tables.length}):`, tables.map(t => t.name).join(', '));
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env node
// server/src/bin/reset-all.js — 清空所有业务数据后重新灌演示数据
// 用法:
// node server/src/bin/reset-all.js # 只清空
// node server/src/bin/reset-all.js --seed # 清空 + 灌演示数据
// node server/src/bin/reset-all.js --help # 查看帮助
import { db, initDb } from '../db.js';
import { loadConfig } from '../config.js';
import crypto from 'node:crypto';
const SEED = process.argv.includes('--seed');
const HELP = process.argv.includes('--help') || process.argv.includes('-h');
if (HELP) {
console.log(`用法: node server/src/bin/reset-all.js [选项]
选项:
--seed 清空业务表后灌入演示数据
--help 显示本帮助`);
process.exit(0);
}
// 初始化数据库连接
await initDb();
const cfg = await loadConfig();
console.log('\n=== 洗车管理系统 — 数据重置工具 ===\n');
// 保留的表(不清理)
const KEEP_TABLES = ['users', 'settings', 'schema_migrations', 'auth_locks', 'login_attempts',
'category_mappings'];
// 所有业务表(按依赖顺序删除,避免外键问题)
const TABLES_IN_ORDER = [
'operation_logs',
'chemical_usage',
'chemical_inventory_log',
'weather_snapshots',
'charging_records',
'refuel_records',
'maintenance_records',
'insurance_records',
'wash_records',
'chemicals',
'vehicles',
'grocy_sync_logs',
];
console.log('▶ 清空业务数据...');
for (const t of TABLES_IN_ORDER) {
await db().run(`DELETE FROM \`${t}\``);
console.log(`${t}`);
}
console.log(`\n✓ 已清空 ${TABLES_IN_ORDER.length} 张业务表`);
console.log(`✓ 保留表: ${KEEP_TABLES.join(', ')}`);
// 灌演示数据
if (!SEED) {
console.log('\n提示:加上 --seed 参数同时灌演示数据');
process.exit(0);
}
console.log('\n▶ 灌入演示数据...');
const today = new Date().toISOString().slice(0, 10);
const ago = (d) => new Date(Date.now() - d * 86400 * 1000).toISOString().slice(0, 10);
const uid = (prefix) => prefix + '-' + crypto.randomUUID().slice(0, 8);
// 1. 车辆
const vehicleDefs = [
{ name: '我的 Tiguan', plate: '粤B12345', type: 'suv', color: '黑色', powertrain: 'hev', notes: '镀晶车 · 2023 款' },
{ name: '领导的爱车', plate: '粤B67890', type: 'car', color: '白色', powertrain: 'ice', notes: '日常代步' },
];
const vehicleIds = [];
for (const v of vehicleDefs) {
const info = await db().run(
`INSERT INTO vehicles (name, plate, type, color, powertrain, notes, is_active, sort_order)
VALUES (?, ?, ?, ?, ?, ?, 1, ?)`,
[v.name, v.plate, v.type, v.color, v.powertrain, v.notes, vehicleIds.length]
);
vehicleIds.push(Number(info.lastInsertRowid));
}
console.log(` ✓ 车辆 ${vehicleIds.length}`);
// 2. 化学品(手动录入)
const chemDefs = [
{ name: 'Adams Q2M BIE', category: '洗车液', unit: '瓶', amount: 3, value: 450, minAmt: 2, loc: '工具箱' },
{ name: 'Adams Q2M WASH', category: '洗车液', unit: '瓶', amount: 2, value: 296, minAmt: 2, loc: '工具箱' },
{ name: 'Adams Q2M HD CURE', category: '养护剂', unit: '瓶', amount: 2, value: 396, minAmt: 1, loc: '工具箱' },
{ name: 'Adams Detail Spray', category: '养护剂', unit: '瓶', amount: 2, value: 180, minAmt: 2, loc: '工具箱' },
{ name: '化学小子金融士', category: '美容剂', unit: '罐', amount: 1, value: 280, minAmt: 1, loc: '储物柜' },
{ name: 'DetailQ 收边毛巾', category: '工具', unit: '条', amount: 8, value: 240, minAmt: 5, loc: '毛巾架' },
{ name: '化学小子脱水毛巾', category: '工具', unit: '条', amount: 5, value: 150, minAmt: 3, loc: '毛巾架' },
{ name: 'Gyeon Q2M FOAM', category: '洗车液', unit: '瓶', amount: 1, value: 168, minAmt: 1, loc: '工具箱' },
];
const chemIds = [];
for (const c of chemDefs) {
const pid = uid('chem');
await db().run(
`INSERT INTO chemicals (grocy_product_id, name, category, unit, current_amount, current_value, min_stock_amount, location, source, is_active, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'manual', 1, NOW())`,
[pid, c.name, c.category, c.unit, c.amount, c.value, c.minAmt, c.loc]
);
chemIds.push(pid);
}
console.log(` ✓ 化学品 ${chemDefs.length}`);
// 3. 洗车记录(每车每月 1-2 条)
const washTypes = ['quick', 'full', 'detail'];
const washLocs = ['自家', '自家', '外面', '外面'];
const washTypeLabels = { quick: '快速', full: '标准', detail: '精洗' };
let washCount = 0;
for (const vid of vehicleIds) {
for (let d = 90; d >= 0; d -= Math.floor(10 + Math.random() * 15)) {
const date = ago(d);
const wt = washTypes[Math.floor(Math.random() * washTypes.length)];
const cost = wt === 'quick' ? 80 : wt === 'full' ? 120 : 280;
const loc = washLocs[Math.floor(Math.random() * washLocs.length)];
const notes = Math.random() > 0.5 ? ['', '镀晶后第一次', '下雨后跑了泥路', '婚车前整备', '例行洗车'][Math.floor(Math.random() * 5)] : '';
await db().run(
`INSERT INTO wash_records (vehicle_id, wash_date, wash_type, location, cost, notes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())`,
[vid, date, wt, loc, cost, notes]
);
washCount++;
}
}
console.log(` ✓ 洗车记录 ${washCount}`);
// 4. 保养记录(shop / total_cost / items_json
const maintDefs = [
{ vid: vehicleIds[0], date: ago(60), odo: 15000, shop: '4S店', cost: 850, items: '["机油","机滤","空滤"]', notes: '首保' },
{ vid: vehicleIds[0], date: ago(30), odo: 15650, shop: '途虎', cost: 420, items: '["机油","机滤"]', notes: '二保' },
{ vid: vehicleIds[1], date: ago(90), odo: 8000, shop: '途虎', cost: 380, items: '["机油","机滤","空调滤"]', notes: '常规保养' },
];
for (const m of maintDefs) {
await db().run(
`INSERT INTO maintenance_records (vehicle_id, maint_date, odometer_km, shop, total_cost, items_json, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`,
[m.vid, m.date, m.odo, m.shop, m.cost, m.items, m.notes]
);
}
console.log(` ✓ 保养记录 ${maintDefs.length}`);
// 5. 加油记录(station / total_cost / price_per_liter
const fuelTypes = ['92#', '95#', '98#'];
const fuelStations = ['中石化', '中石油', '壳牌', '民营油站'];
for (const vid of vehicleIds) {
let odo = vid === vehicleIds[0] ? 14000 : 8000;
for (let d = 60; d >= 0; d -= Math.floor(5 + Math.random() * 5)) {
const liters = 40 + Math.random() * 20;
const price = 7.5 + Math.random() * 1.0;
odo += Math.floor(400 + Math.random() * 200);
await db().run(
`INSERT INTO refuel_records (vehicle_id, refuel_date, odometer_km, fuel_type, liters, price_per_liter, is_full, total_cost, station, created_at)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, NOW())`,
[vid, ago(d), odo, fuelTypes[Math.floor(Math.random() * fuelTypes.length)],
Math.round(liters * 10) / 10, Math.round(price * 100) / 100,
Math.round(liters * price * 10) / 10, fuelStations[Math.floor(Math.random() * fuelStations.length)]]
);
}
}
console.log(` ✓ 加油记录 (每车 ~8 条)`);
// 6. 充电记录(station / price_per_kwh / total_cost
for (const vid of vehicleIds) {
let odo = vid === vehicleIds[0] ? 14200 : 8200;
for (let d = 45; d >= 0; d -= Math.floor(7 + Math.random() * 7)) {
const kwh = 15 + Math.random() * 15;
const price = Math.random() > 0.5 ? 0.5 : 1.5; // 谷时/峰时单价
const sSoc = Math.floor(20 + Math.random() * 20);
const eSoc = sSoc + Math.floor(30 + Math.random() * 40);
const ctype = Math.random() > 0.6 ? 'public' : 'home';
const station = ctype === 'home' ? '自家桩' : '快充站';
odo += Math.floor(80 + Math.random() * 120);
await db().run(
`INSERT INTO charging_records (vehicle_id, charge_date, odometer_km, charge_type, kwh, price_per_kwh, total_cost, station, start_soc, end_soc, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())`,
[vid, ago(d), odo, ctype, Math.round(kwh * 10) / 10, Math.round(price * 100) / 100,
Math.round(kwh * price * 100) / 100, station, sSoc, Math.min(eSoc, 100), ctype === 'home' ? '谷时充电' : '']
);
}
}
console.log(` ✓ 充电记录 (每车 ~5 条)`);
// 7. 保险记录(company / policy_no / premium
const insTypes = ['交强险', '商业险', '三者险'];
const insurers = ['平安保险', '太平洋保险', '人保'];
for (const vid of vehicleIds) {
for (let m = 12; m >= 0; m -= 12) {
const startDate = new Date(Date.now() - m * 30 * 86400 * 1000);
const endDate = new Date(startDate.getTime() + 365 * 86400 * 1000);
await db().run(
`INSERT INTO insurance_records (vehicle_id, insurance_type, company, policy_no, premium, start_date, end_date, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`,
[vid, insTypes[Math.floor(Math.random() * insTypes.length)],
insurers[Math.floor(Math.random() * insurers.length)],
'POL' + Math.floor(1e9 + Math.random() * 9e9),
950 + Math.floor(Math.random() * 3500),
startDate.toISOString().slice(0, 10),
endDate.toISOString().slice(0, 10)]
);
}
}
console.log(` ✓ 保险记录 (每车 ~2 种)`);
// 8. 操作日志样本
for (const vid of vehicleIds) {
await db().run(
`INSERT INTO operation_logs (user_id, username, action, target_type, target_ids, target_summary, created_at)
VALUES (1, 'admin2', 'create', 'vehicle', ?, ?, NOW())`,
[vid, '初始导入车辆 id=' + vid]
);
}
console.log(` ✓ 操作日志 (每车 1 条)`);
// 打印汇总
console.log('\n=== 当前数据汇总 ===');
const stats = {
vehicles: (await db().get('SELECT COUNT(*) c FROM vehicles'))?.c || 0,
chemicals: (await db().get('SELECT COUNT(*) c FROM chemicals WHERE is_active=1'))?.c || 0,
washes: (await db().get('SELECT COUNT(*) c FROM wash_records'))?.c || 0,
maint: (await db().get('SELECT COUNT(*) c FROM maintenance_records'))?.c || 0,
refuels: (await db().get('SELECT COUNT(*) c FROM refuel_records'))?.c || 0,
charges: (await db().get('SELECT COUNT(*) c FROM charging_records'))?.c || 0,
insurances: (await db().get('SELECT COUNT(*) c FROM insurance_records'))?.c || 0,
oplogs: (await db().get('SELECT COUNT(*) c FROM operation_logs'))?.c || 0,
};
for (const [k, v] of Object.entries(stats)) console.log(` ${k.padEnd(12)} ${v}`);
const totalCost = (await db().get('SELECT ROUND(COALESCE(SUM(cost),0),2) c FROM wash_records'))?.c || 0;
console.log(` 累计洗车花费 ¥ ${totalCost}`);
console.log('\n✅ 重置完成!请刷新页面。');
process.exit(0);
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env node
// server/src/bin/seed-demo.js — 灌入演示数据
// 用法:
// node server/src/bin/seed-demo.js # 追加(保留旧数据)
// node server/src/bin/seed-demo.js --reset # 清掉业务表后重新灌
import { db } from '../db.js';
import { loadConfig } from '../config.js';
import { fetchToday } from '../services/weather.js';
const cfg = await loadConfig();
const RESET = process.argv.includes('--reset');
const today = new Date().toISOString().slice(0, 10);
const ago = (d) => new Date(Date.now() - d * 86400 * 1000).toISOString().slice(0, 10);
console.log('开始灌入演示数据...');
if (RESET) {
console.log(' --reset:清空业务表');
db().exec('DELETE FROM chemical_usage');
db().exec('DELETE FROM wash_records');
db().exec('DELETE FROM weather_snapshots');
db().exec('DELETE FROM vehicles');
db().exec('DELETE FROM chemicals');
}
const existingWash = (await db().get('SELECT COUNT(*) c FROM wash_records')).c;
if (existingWash > 0 && !RESET) {
console.log(`⚠ 已有 ${existingWash} 条 wash_records,跳过灌入(加 --reset 强制重新灌)`);
printSummary();
process.exit(0);
}
// 1. 演示车辆
const vehicleDefs = [
{ name: '我的小车', plate: '京A·12345', type: 'car', color: '白色', notes: '日常通勤 · 2021 款' },
{ name: '家庭 SUV', plate: '京B·88888', type: 'suv', color: '黑色', notes: '周末出游 · 2023 款' },
{ name: '代步小车', plate: '京C·66666', type: 'car', color: '银色', notes: '买菜接娃' },
];
const vehicleIds = [];
for (const v of vehicleDefs) {
const info = db().prepare(
`INSERT INTO vehicles (name, plate, type, color, notes, sort_order) VALUES (?, ?, ?, ?, ?, ?)`
).run(v.name, v.plate, v.type, v.color, v.notes, vehicleIds.length);
vehicleIds.push(Number(info.lastInsertRowid));
}
console.log(`✓ 车辆: ${vehicleIds.length} 辆 (id: ${vehicleIds.join(', ')})`);
// 2. 化学品主数据
const chemicals = [
{ id: 'DETERGENT-FOAM', name: '洗车液(泡沫)', unit: 'L', category: 'cleaning', dose: 0.3 },
{ id: 'WAX-CARNUBA', name: '棕榈车蜡', unit: 'g', category: 'polish', dose: 50 },
{ id: 'GLASS-CLEAN', name: '玻璃水', unit: 'L', category: 'cleaning', dose: 0.2 },
{ id: 'TIRE-GEL', name: '轮胎增亮剂', unit: 'ml', category: 'polish', dose: 30 },
{ id: 'INTERIOR-CLEAN', name: '内饰清洁剂', unit: 'ml', category: 'interior', dose: 80 },
{ id: 'QUICK-DETAIL', name: '快速镀膜喷雾', unit: 'ml', category: 'polish', dose: 25 },
{ id: 'WHEEL-CLEAN', name: '轮毂清洁剂', unit: 'ml', category: 'cleaning', dose: 40 },
];
for (const c of chemicals) {
db().prepare(
`INSERT INTO chemicals (grocy_product_id, name, unit, category, standard_dose, is_active, fetched_at, updated_at)
VALUES (?, ?, ?, ?, ?, 1, NOW(), NOW())`
).run(c.id, c.name, c.unit, c.category, c.dose);
}
console.log(`✓ 化学品: ${chemicals.length}`);
// 3. 天气快照(90 天,每天一条 mock)
const weatherConditions = [
{ code: '100', desc: '晴', min: 18, max: 32, humidity: 45 },
{ code: '101', desc: '多云', min: 16, max: 28, humidity: 55 },
{ code: '102', desc: '少云', min: 18, max: 30, humidity: 50 },
{ code: '104', desc: '阴', min: 14, max: 22, humidity: 70 },
{ code: '305', desc: '小雨', min: 12, max: 18, humidity: 85 },
{ code: '306', desc: '中雨', min: 10, max: 16, humidity: 90 },
{ code: '501', desc: '雾', min: 8, max: 14, humidity: 95 },
];
const seedRandom = (seed) => { let s = seed; return () => { s = (s * 9301 + 49297) % 233280; return s / 233280; }; };
const rand = seedRandom(20260617);
const weatherIds = {};
for (let d = 90; d >= 0; d--) {
const date = ago(d);
const wc = weatherConditions[Math.floor(rand() * weatherConditions.length)];
const temp = wc.min + rand() * (wc.max - wc.min);
const wid = db().prepare(
`INSERT INTO weather_snapshots (city, provider, temp_c, humidity, weather_desc, weather_code, wind_kph, precip_mm, snapshot_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run('Beijing', 'qweather', Math.round(temp * 10) / 10, Math.round(wc.humidity + (rand() - 0.5) * 10), wc.desc, wc.code, Math.round(rand() * 20 * 10) / 10, wc.code.startsWith('3') || wc.code.startsWith('5') ? Math.round(rand() * 5 * 10) / 10 : 0, date).lastInsertRowid;
weatherIds[date] = wid;
}
console.log(`✓ 天气快照: 91 天`);
// 4. 洗车记录(30 条,分布在 90 天里)
const typeMap = [
{ t: 'quick', cost: [15, 25], dur: [15, 30], weight: 5 }, // 50% 快速
{ t: 'full', cost: [35, 60], dur: [40, 70], weight: 3 }, // 30% 标准
{ t: 'detail', cost: [100, 200], dur: [80, 120], weight: 2 }, // 20% 精洗
];
const weightedType = () => {
const total = typeMap.reduce((a, x) => a + x.weight, 0);
let r = rand() * total;
for (const x of typeMap) { r -= x.weight; if (r <= 0) return x; }
return typeMap[0];
};
const locations = ['家', '公司', '家', '家', '楼下洗车店', '4S 店', '自助洗车'];
const washIds = [];
for (let i = 0; i < 30; i++) {
// 随机日期(在 90 天里,靠近今天的概率高)
const d = Math.floor(rand() * 90 * rand()); // 平方分布,最近的更多
const date = ago(d);
const type = weightedType();
const cost = Math.round((type.cost[0] + rand() * (type.cost[1] - type.cost[0])) * 100) / 100;
const dur = Math.round(type.dur[0] + rand() * (type.dur[1] - type.dur[0]));
const vid = vehicleIds[Math.floor(rand() * vehicleIds.length)];
const wid = weatherIds[date];
const loc = locations[Math.floor(rand() * locations.length)];
const notes = rand() < 0.3 ? ['赶时间', '顺路', '朋友推荐', '促销', ''][Math.floor(rand() * 5)] : '';
const info = db().prepare(
`INSERT INTO wash_records (wash_date, wash_type, location, cost, duration_min, vehicle_id, weather_snapshot_id, notes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`
).run(date, type.t, loc, cost, dur, vid, wid, notes);
washIds.push({ id: Number(info.lastInsertRowid), type: type.t, date });
}
console.log(`✓ 洗车记录: ${washIds.length}`);
// 5. 化学品使用(每条洗车记录关联 1-4 种)
let usageCount = 0;
for (const w of washIds) {
const used = [];
// 必用:洗车液
used.push({ id: 'DETERGENT-FOAM', amt: 0.25 + rand() * 0.2 });
// 按类型加
if (w.type === 'full' || w.type === 'detail') {
used.push({ id: 'WAX-CARNUBA', amt: 40 + Math.floor(rand() * 30) });
used.push({ id: 'GLASS-CLEAN', amt: 0.15 + Math.round(rand() * 15) / 100 });
}
if (w.type === 'detail') {
used.push({ id: 'TIRE-GEL', amt: 25 + Math.floor(rand() * 15) });
used.push({ id: 'INTERIOR-CLEAN', amt: 60 + Math.floor(rand() * 50) });
used.push({ id: 'QUICK-DETAIL', amt: 20 + Math.floor(rand() * 15) });
used.push({ id: 'WHEEL-CLEAN', amt: 30 + Math.floor(rand() * 20) });
} else if (rand() < 0.3) {
// 偶尔用快速镀膜
used.push({ id: 'QUICK-DETAIL', amt: 20 + Math.floor(rand() * 15) });
}
for (const u of used) {
db().prepare(
`INSERT INTO chemical_usage (usage_date, chemical_id, amount, wash_record_id, sync_status, created_at, updated_at)
VALUES (?, ?, ?, ?, 'pending', NOW(), NOW())`
).run(w.date, u.id, u.amt, w.id);
usageCount++;
}
}
console.log(`✓ 化学品使用: ${usageCount}`);
printSummary();
process.exit(0);
function printSummary() {
console.log('\n=== 当前数据 ===');
console.log(` 车辆: ${(await db().get('SELECT COUNT(*) c FROM vehicles', [).c}`]));
console.log(` 化学品(启用): ${(await db().get('SELECT COUNT(*) c FROM chemicals WHERE is_active=1', [).c}`]));
console.log(` 洗车记录: ${(await db().get('SELECT COUNT(*) c FROM wash_records', [).c}`]));
console.log(` 化学品使用: ${(await db().get('SELECT COUNT(*) c FROM chemical_usage', [).c}`]));
console.log(` 天气快照: ${(await db().get('SELECT COUNT(*) c FROM weather_snapshots', [).c}`]));
const first = (await db().get('SELECT MIN(wash_date) d FROM wash_records')).d;
const last = (await db().get('SELECT MAX(wash_date) d FROM wash_records')).d;
const totalCost = (await db().get('SELECT ROUND(SUM(cost), 2) c FROM wash_records')).c;
console.log(` 时间范围: ${first}${last}`);
console.log(` 累计花费: ¥ ${totalCost}`);
}
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env node
// server/src/bin/serve.js — 启动 HTTP 服务(统一入口)
// 注意:isSetupDone 和 dotenv 加载都在 index.js 里,这里只负责启动
const port = Number(process.env.PORT || 8787);
const host = process.env.HOST || '0.0.0.0';
// Import index.js — it loads dotenv, checks .setup_done, sets up routing, connects DB
const { app, isSetupDone } = await import('../index.js');
app.listen(port, host, () => {
console.log('[server] http://' + host + ':' + port);
if (!isSetupDone) {
console.log('[server] DB: 待配置(首次安装模式)');
console.log('[server] → http://localhost:' + port + '/setup 完成首次配置');
} else {
console.log('[server] DB: MySQL ' + (process.env.DB_NAME || 'unknown') + '@' + (process.env.DB_HOST || '127.0.0.1') + ':' + (process.env.DB_PORT || 3306));
}
});
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
// server/src/bin/users.js — 用户管理 CLI
import { db } from '../db.js';
import { hashPassword, verifyPassword, userExists, changePassword, setActive, deleteUser } from '../services/auth.js';
const cmd = process.argv[2];
if (!cmd || cmd === '--help' || cmd === '-h') {
console.log(`Usage:
users list 列出所有用户
users add <username> <password> [--admin] 新建用户
users passwd <username> <newPassword> 修改密码
users disable <username> 禁用用户
users enable <username> 启用用户
users remove <username> [--force] 删除用户(--force 跳过确认)
users verify <username> <password> 验证密码`);
process.exit(0);
}
async function main() {
if (cmd === 'list') {
const rows = await db().all('SELECT id, username, role, is_active, last_login_at, created_at FROM users ORDER BY id');
console.log(`${rows.length} 个用户:`);
for (const r of rows) {
console.log(` #${r.id} ${r.username} [${r.role}] ${r.is_active ? '✓' : '✗ disabled'} 上次登录: ${r.last_login_at || '-'}`);
}
process.exit(0);
}
if (cmd === 'add') {
const [,,, username, password, ...flags] = process.argv;
if (!username || !password) { console.error('用法: users add <username> <password> [--admin]'); process.exit(2); }
if (await userExists(username)) { console.error(`✗ 用户已存在: ${username}`); process.exit(1); }
const isAdmin = flags.includes('--admin');
const { createUser } = await import('../services/auth.js');
const id = await createUser(username, password, 12);
console.log(`✓ 已创建用户 ${username} (id=${id}, role=${isAdmin ? 'admin' : 'user'})`);
process.exit(0);
}
if (cmd === 'passwd') {
const [,,, username, newPassword] = process.argv;
if (!username || !newPassword) { console.error('用法: users passwd <username> <newPassword>'); process.exit(2); }
const u = await db().get('SELECT id FROM users WHERE username = ?', [username]);
if (!u) { console.error(`✗ 用户不存在: ${username}`); process.exit(1); }
await changePassword(u.id, newPassword);
console.log(`✓ 已更新 ${username} 的密码`);
process.exit(0);
}
if (cmd === 'disable' || cmd === 'enable') {
const [,,, username] = process.argv;
if (!username) { console.error(`用法: users ${cmd} <username>`); process.exit(2); }
const r = await db().run('UPDATE users SET is_active = ?, updated_at = NOW() WHERE username = ?', [cmd === 'disable' ? 0 : 1, username]);
if (r.changes === 0) { console.error(`✗ 用户不存在: ${username}`); process.exit(1); }
console.log(`${username} ${cmd === 'disable' ? '已禁用' : '已启用'}`);
process.exit(0);
}
if (cmd === 'remove') {
const [,,, username, ...flags] = process.argv;
const force = flags.includes('--force');
if (!username) { console.error('用法: users remove <username> [--force]'); process.exit(2); }
if (!force) {
console.log(`⚠ 确认删除用户 ${username}? 这是不可逆操作。`);
console.log(` 重跑加上 --force 跳过此提示。`);
process.exit(2);
}
const r = await db().run('DELETE FROM users WHERE username = ?', [username]);
if (r.changes === 0) { console.error(`✗ 用户不存在: ${username}`); process.exit(1); }
console.log(`✓ 已删除 ${username}`);
process.exit(0);
}
if (cmd === 'verify') {
const [,,, username, password] = process.argv;
if (!username || !password) { console.error('用法: users verify <username> <password>'); process.exit(2); }
const ok = await verifyPassword(username, password);
console.log(ok ? `✓ 密码正确` : `✗ 密码错误`);
process.exit(ok ? 0 : 1);
}
console.error(`未知子命令: ${cmd}`);
process.exit(2);
}
main().catch(e => { console.error(e.message); process.exit(1); });
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env node
// server/src/bin/verify.js — 端到端自检
import { db } from '../db.js';
import { loadConfig } from '../config.js';
const cfg = await loadConfig();
let pass = 0, fail = 0, total = 0;
const results = [];
function check(name, cond, detail) {
total++;
if (cond) { pass++; results.push({ name, ok: true, detail }); }
else { fail++; results.push({ name, ok: false, detail }); }
}
// 1. 数据库文件存在
const dbFile = process.env.DB_PATH || 'server/data/carwash.sqlite';
const fs = await import('node:fs');
const exists = fs.existsSync(dbFile);
check('db_file_exists', exists, dbFile);
// 2. 表数量 (9 张)
const tables = db().prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all().map(r => r.name);
const expectedTables = ['chemicals', 'weather_snapshots', 'wash_records', 'chemical_usage', 'settings', 'users', 'login_attempts', 'auth_locks', 'vehicles'];
const missingTables = expectedTables.filter(t => !tables.includes(t));
check('tables_present', missingTables.length === 0, `${tables.length} 张表,缺失: ${missingTables.join(',') || '无'}`);
// 3. 视图数量 (3 张)
const views = db().prepare("SELECT name FROM sqlite_master WHERE type='view'").all().map(r => r.name);
check('views_present', views.length >= 3, `视图: ${views.join(', ')}`);
// 4. 默认管理员存在
const admin = db().prepare("SELECT id, is_active FROM users WHERE username = 'admin'").get();
check('admin_user_exists', !!admin, admin ? `id=${admin.id}, active=${admin.is_active}` : '缺失');
// 5. settings 至少 22 条
const sCount = db().prepare("SELECT COUNT(*) c FROM settings").get().c;
check('settings_count', sCount >= 22, `${sCount}`);
// 6. auth_locks 表可写
const t = db().prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='auth_locks'").get();
check('auth_locks_table', !!t, '存在');
// 7. vehicles 表 + 默认字段
const vFields = db().prepare("PRAGMA table_info(vehicles)").all().map(c => c.name);
const expectedVFields = ['id', 'name', 'plate', 'color', 'notes', 'is_active', 'created_at', 'updated_at'];
check('vehicles_schema', expectedVFields.every(f => vFields.includes(f)), vFields.join(', '));
// 8. wash_records 关联 vehicle_id
const wrFields = db().prepare("PRAGMA table_info(wash_records)").all().map(c => c.name);
check('wash_records_vehicle_id', wrFields.includes('vehicle_id'), 'vehicle_id 字段存在');
// 9. v_last_wash 视图包含车辆
const vlastDef = views.includes('v_last_wash') ? db().prepare("SELECT sql FROM sqlite_master WHERE name='v_last_wash'").get()?.sql : '';
check('v_last_wash_vehicle', vlastDef && /vehicles|vehicle_id/i.test(vlastDef), vlastDef?.slice(0, 200));
// 10. config 加载
check('config_loaded', !!cfg && !!cfg.app && !!cfg.weather, `app.city=${cfg.app?.city}, weather.provider=${cfg.weather?.provider}`);
// 11. config 嵌套结构完整
const configKeys = ['app', 'weather', 'grocy', 'auth', 'csrf', 'session'];
check('config_keys', configKeys.every(k => cfg[k] !== undefined), configKeys.map(k => `${k}=${typeof cfg[k]}`).join(' '));
// 12. weather config 字段
const weatherKeys = ['provider', 'qweather_key', 'qweather_host', 'openweathermap_key'];
check('weather_config_fields', weatherKeys.every(k => cfg.weather[k] !== undefined), '全部字段');
// 13. grocy config 字段
const grocyKeys = ['url', 'api_token'];
check('grocy_config_fields', grocyKeys.every(k => cfg.grocy[k] !== undefined), '全部字段');
// 14. auth config 字段
const authKeys = ['bcrypt_cost', 'login_max_failures_ip', 'login_max_failures_user', 'login_lock_minutes_ip'];
check('auth_config_fields', authKeys.every(k => cfg.auth[k] !== undefined), '全部字段');
// 15. session / csrf config
check('csrf_config', typeof cfg.csrf?.token_lifetime_hours === 'number', `lifetime=${cfg.csrf?.token_lifetime_hours}h`);
check('session_config', typeof cfg.session?.lifetime_days === 'number', `lifetime=${cfg.session?.lifetime_days}`);
// 16. CSRF token 生成
const { csrfToken } = await import('../services/auth.js');
// csrfToken(req) — 模拟一个空 session req
const fakeReq = { session: {} };
const tok = csrfToken(fakeReq);
check('csrf_token_gen', typeof tok === 'string' && tok.length >= 32, `长度 ${tok.length}`);
// 17. password hash
const { hashPassword, compareHash } = await import('../services/auth.js');
const h = hashPassword('test123');
const v = compareHash('test123', h);
check('password_hash', v && h.startsWith('$2'), `bcrypt hash ok`);
// 18. rate limit helper
const { isLocked, recordFailure, recordSuccess } = await import('../services/rateLimit.js');
check('rate_limit_module', typeof isLocked === 'function' && typeof recordFailure === 'function', 'API 完整');
// 19. weather service mock
const { fetchToday } = await import('../services/weather.js');
const w = await fetchToday('Beijing', cfg);
check('weather_service_returns', w && w.city === 'Beijing' && typeof w.temp_c === 'number', `temp=${w.temp_c}°C, desc=${w.weather_desc}`);
// 20. exporter 模块可加载
const { exportCsv } = await import('../services/exporter.js');
check('exporter_module', typeof exportCsv === 'function', 'exportCsv 存在');
// 21. backup 模块可加载
const { runBackup } = await import('../services/backup.js');
check('backup_module', typeof runBackup === 'function', 'runBackup 存在');
// 22. grocy 模块可加载
const { syncUsageToGrocy } = await import('../services/grocy.js');
const { refreshProducts } = await import('../services/grocyProducts.js');
check('grocy_modules', typeof syncUsageToGrocy === 'function' && typeof refreshProducts === 'function', 'sync + refresh');
// 23. routes 存在
const routes = await import('../routes/auth.js');
check('routes_auth', typeof routes.default === 'function', 'express router');
// 24. middleware 存在
const mw = await import('../middleware/auth.js');
const mwCsrf = await import('../middleware/csrf.js');
check('middleware_auth_csrf', typeof mw.requireAuth === 'function' && typeof mwCsrf.requireCsrf === 'function', 'requireAuth + requireCsrf');
// 25. http 工具
const httpMod = await import('../http.js');
check('http_module', typeof httpMod.httpGet === 'function' && typeof httpMod.httpPost === 'function', 'httpGet + httpPost');
// 26. SPA 入口文件存在
const htmlFile = 'client/index.html';
check('client_index', fs.existsSync(htmlFile), htmlFile);
// 27. vite.config 存在
check('vite_config', fs.existsSync('client/vite.config.js'), 'client/vite.config.js');
// 28. package.json 三个
check('root_pkg', fs.existsSync('package.json'), 'package.json');
check('server_pkg', fs.existsSync('server/package.json'), 'server/package.json');
check('client_pkg', fs.existsSync('client/package.json'), 'client/package.json');
// 输出
console.log('\n=== 验证结果 ===\n');
for (const r of results) {
const icon = r.ok ? '✓' : '✗';
console.log(` ${icon} ${r.name}${r.detail ? ' — ' + r.detail : ''}`);
}
console.log(`\n通过: ${pass} / ${total} 失败: ${fail}`);
process.exit(fail > 0 ? 1 : 0);
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env node
// server/src/bin/weather.js — 拉取今日天气
import { cli } from '../services/weather.js';
import { loadConfig } from '../config.js';
const cfg = await loadConfig();
await cli(process.argv.slice(2), cfg);
+91
View File
@@ -0,0 +1,91 @@
// server/src/config.js — 加载 .env + settings 表,组装 config 对象
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
import { db } from './db.js';
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
/** 加载 .env(如果存在) */
export function loadDotenv() {
const envFile = path.join(__dirname, '../../.env');
if (!fs.existsSync(envFile)) return;
for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const k = trimmed.slice(0, eq).trim();
const v = trimmed.slice(eq + 1).trim();
if (k && process.env[k] === undefined) process.env[k] = v;
}
}
/**
* 加载 config.env 优先 → settings 表 → 代码默认
* 返回结构:
* {
* app: { city, timezone, env, debug },
* weather: { provider, qweather_key, qweather_host, openweathermap_key },
* grocy: { url, api_token },
* backup: { dir, keep_count },
* auth: { bcrypt_cost, login_max_failures_ip, login_max_failures_user, ... },
* csrf: { token_lifetime_hours },
* session: { lifetime_days, cookie_secure },
* }
*/
export async function loadConfig() {
loadDotenv();
const settings = {};
try {
const rows = (await db().all('SELECT `key`, value FROM settings'));
for (const r of rows) settings[r.key] = r.value ?? '';
} catch (e) { /* settings 表未建时忽略 */ }
const num = (k, d) => {
const v = settings[k] ?? process.env[k.toUpperCase()] ?? d;
return v === '' || v === null || v === undefined ? d : Number(v);
};
const str = (k, d) => settings[k] ?? process.env[k.toUpperCase()] ?? d;
const bool = (k, d) => {
const v = (settings[k] ?? process.env[k.toUpperCase()] ?? String(d)).toLowerCase();
return v === 'true' || v === '1' || v === 'yes';
};
return {
app: {
city: str('app_city', 'auto'), // 'auto' = 根据 IP 自动定位;手动设置当天有效
timezone: str('app_timezone', 'Asia/Shanghai'),
env: str('APP_ENV', 'production'),
debug: bool('APP_DEBUG', false),
},
weather: {},
grocy: {
url: str('grocy_url', ''),
api_key: str('grocy_api_key', ''), // API Key 鉴权(优先)
basic_user: str('grocy_username', ''), // session cookie 鉴权(备用)
basic_pass: str('grocy_password', ''),
},
backup: {
dir: str('backup_dir', 'storage/backups'),
keep_count: num('backup_keep_count', 10),
},
auth: {
bcrypt_cost: num('bcrypt_cost', 12),
login_max_failures_ip: num('login_max_failures_ip', 5),
login_max_failures_user: num('login_max_failures_user', 5),
login_lock_minutes_ip: num('login_lock_minutes_ip', 15),
login_lock_minutes_user: num('login_lock_minutes_user', 30),
login_global_max_failures: num('login_global_max_failures', 10),
login_global_lock_hours: num('login_global_lock_hours', 1),
login_attempts_retention_days: num('login_attempts_retention_days', 30),
},
csrf: {
token_lifetime_hours: num('csrf_token_lifetime_hours', 12),
},
session: {
lifetime_days: num('session_lifetime_days', 30),
cookie_secure: str('session_cookie_secure', 'auto'), // 'true'/'false'/'auto'
},
};
}
+341
View File
@@ -0,0 +1,341 @@
// server/src/db.js — 统一 DB 接口:MySQL 优先 / SQLite 回退
import Database from 'better-sqlite3';
import mysql from 'mysql2/promise';
import path from 'node:path';
import fs from 'node:fs';
import url from 'node:url';
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
// ============================================================
// 判断使用哪种数据库
// ============================================================
function detectDriver() {
// 优先用 MySQL(连接串在 .env 或环境变量里)
if (process.env.DB_HOST || process.env.DB_URL) return 'mysql';
// SQLite 回退
return 'sqlite';
}
// ============================================================
// SQLite 实现(向后兼容)
// ============================================================
function makeSqlite(db) {
return {
all(sql, params = []) {
return db.prepare(sql).all(...params);
},
get(sql, params = []) {
return db.prepare(sql).get(...params);
},
run(sql, params = []) {
return db.prepare(sql).run(...params);
},
exec(sql) {
return db.exec(sql);
},
transaction(fn) {
return db.transaction(fn)();
},
close() {
db.close();
},
driver: 'sqlite',
};
}
// ============================================================
// MySQL 实现
// ============================================================
let _pool = null;
async function makePool(cfg) {
if (_pool) return _pool;
const baseOpts = {
waitForConnections: true,
connectionLimit: 10,
timezone: 'Z', // 所有 datetime 存 UTC,读出直接当 UTC,不做任何偏移
// 关键:MySQL 默认 wait_timeout=28800s 会关掉 idle 连接,不开 keepAlive 下次 query 会 ETIMEDOUT
enableKeepAlive: true,
keepAliveInitialDelay: 30_000, // 30s 后开始发 ping
connectTimeout: 10_000,
};
const urlStr = process.env.DB_URL;
if (urlStr) {
// 格式: mysql://user:pass@host:port/database
const u = new URL(urlStr);
_pool = mysql.createPool({
...baseOpts,
host: u.hostname,
port: Number(u.port) || 3306,
user: u.username,
password: u.password,
database: u.pathname.slice(1),
});
} else {
_pool = mysql.createPool({
...baseOpts,
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'carwash',
});
}
return _pool;
}
// query 包装:遇到 ETIMEDOUT/ECONNRESET 自动重试一次(连接死了建新连接)
async function queryWithRetry(pool, sql, params) {
for (let attempt = 0; attempt < 2; attempt++) {
try {
return await pool.query(sql, params);
} catch (e) {
const code = e.code || '';
const retryable = code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'PROTOCOL_CONNECTION_LOST';
if (retryable && attempt === 0) continue;
throw e;
}
}
}
function makeMysql(pool) {
return {
async all(sql, params = []) {
const [rows] = await queryWithRetry(pool, sql, params);
return rows;
},
async get(sql, params = []) {
const [rows] = await queryWithRetry(pool, sql, params);
return rows[0] || null;
},
async run(sql, params = []) {
const [result] = await queryWithRetry(pool, sql, params);
return {
changes: result.affectedRows,
lastInsertRowid: result.insertId,
};
},
async exec(sql) {
await queryWithRetry(pool, sql, []);
},
// SQLite-compatible prepare() shim for MySQL
// Translates SQLite datetime syntax and COLLATE NOCASE to MySQL
prepare(sql) {
const mysqlSql = sql
.replace(/datetime\('now'\)/gi, 'NOW()')
.replace(/datetime\("now"\)/gi, 'NOW()')
.replace(/\bCOLLATE\s+NOCASE\b/gi, ''); // MySQL uses case-insensitive collations by default
return {
get: (...params) => queryWithRetry(pool, mysqlSql, params).then(([rows]) => rows[0] || null),
all: (...params) => queryWithRetry(pool, mysqlSql, params).then(([rows]) => rows),
run: (...params) =>
queryWithRetry(pool, mysqlSql, params).then(([result]) => ({
changes: result.affectedRows,
lastInsertRowid: result.insertId,
})),
};
},
async transaction(fn) {
const conn = await pool.getConnection();
await conn.beginTransaction();
try {
const proxied = {
all: (sql, p = []) => conn.query(sql, p).then(([r]) => r),
get: (sql, p = []) => conn.query(sql, p).then(([r]) => r[0] || null),
run: (sql, p = []) =>
conn.query(sql, p).then(([r]) => ({
changes: r.affectedRows,
lastInsertRowid: r.insertId,
})),
exec: (sql) => conn.query(sql),
prepare: (sql) => ({
all: (...p) => conn.query(sql, p).then(([r]) => r),
get: (...p) => conn.query(sql, p).then(([r]) => r[0] || null),
run: (...p) =>
conn.query(sql, p).then(([r]) => ({
changes: r.affectedRows,
lastInsertRowid: r.insertId,
})),
}),
};
await fn(proxied);
await conn.commit();
} catch (e) {
await conn.rollback();
throw e;
} finally {
conn.release();
}
},
close() {
pool.end();
_pool = null;
},
driver: 'mysql',
};
}
// ============================================================
// 主接口
// ============================================================
let _sql = null; // 统一 sql 对象({ all, get, run, exec, transaction, close, driver }
let _rawDb = null; // SQLite raw 对象(仅 sqlite 驱动需要)
let _driver = null;
export const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../data', 'carwash.sqlite');
export function sql() {
if (!_sql) throw new Error('DB not initialized. Call initDb() first.');
return _sql;
}
export async function initDb(opts = {}) {
// 先加载 .envbin 脚本场景下 loadConfig 还没跑)
const envFile = path.join(__dirname, '../../.env');
if (fs.existsSync(envFile)) {
for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
const k = t.slice(0, eq).trim(),
v = t.slice(eq + 1).trim();
if (k && process.env[k] === undefined) process.env[k] = v;
}
}
_driver = detectDriver();
if (_driver === 'mysql') {
const pool = await makePool({});
_rawDb = pool;
_sql = makeMysql(pool);
console.log(`[db] MySQL connected (${process.env.DB_NAME || 'carwash'})`);
} else {
const dir = path.dirname(DB_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
_rawDb = new Database(DB_PATH);
_rawDb.pragma('journal_mode = WAL');
_rawDb.pragma('foreign_keys = ON');
_rawDb.pragma('synchronous = NORMAL');
_rawDb.pragma('busy_timeout = 5000');
_sql = makeSqlite(_rawDb);
console.log(`[db] SQLite: ${DB_PATH}`);
}
return _driver;
}
/** 当前驱动:'mysql' | 'sqlite' */
export function driver() {
return _driver;
}
/** db() 兼容:await initDb() 后可直接 sql().get/all/run */
export function db() {
return sql();
}
/** 迁移:跑所有未跑过的 migration */
export async function migrate(opts = {}) {
const s = sql();
const verbose = !!opts.verbose;
// 1) ensure schema_migrations exists
if (_driver === 'mysql') {
await s.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
filename VARCHAR(255) PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`);
} else {
s.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`);
}
// 2) get applied set
const applied = new Set((await s.all('SELECT filename FROM schema_migrations')).map((r) => r.filename));
// 3) migration files (MySQL 用 mysql/ 子目录)
const baseDir = path.join(__dirname, '../migrations');
const subDir = _driver === 'mysql' ? path.join(baseDir, 'mysql') : baseDir;
const files = fs
.readdirSync(subDir)
.filter((f) => f.endsWith('.sql'))
.sort();
let appliedCount = 0;
for (const f of files) {
if (applied.has(f)) {
if (verbose) console.log(` · ${f} (already applied)`);
continue;
}
const sqlText = fs.readFileSync(path.join(subDir, f), 'utf8');
// Split into individual statements (each SQL statement ends with ';')
// First chunk may contain leading -- comments merged with a real statement; strip them
const stmts = [];
let pos = 0,
idx = 0;
while ((idx = sqlText.indexOf(';', pos)) !== -1) {
let stmt = sqlText.slice(pos, idx + 1).trim();
// Remove leading -- comment lines (they may be merged with first real statement)
stmt = stmt.replace(/^(?:--[^\n]*\n\s*)+/, '').trim();
if (stmt && stmt.length > 0) stmts.push(stmt);
pos = idx + 1;
while (pos < sqlText.length && (sqlText[pos] === '\n' || sqlText[pos] === '\r')) pos++;
}
try {
await s.transaction(async (tx) => {
for (const stmt of stmts) await tx.exec(stmt);
await tx.run('INSERT INTO schema_migrations (filename) VALUES (?)', [f]);
});
console.log(`${f}`);
appliedCount++;
} catch (e) {
console.error(`${f} failed: ${e.message}`);
throw e;
}
}
return { applied: appliedCount, total: files.length };
}
export function close() {
if (_sql) {
_sql.close();
_sql = null;
_rawDb = null;
}
}
// ============================================================
// 软删除 helper:给 SELECT/UPDATE/DELETE 自动追加 is_deleted = 0
// ------------------------------------------------------------
// 用法:
// const rows = await db().all(softWhere('vehicles', `SELECT * FROM vehicles WHERE id = ?`), [id]);
// const row = await db().get(softWhere('washes', `SELECT * FROM wash_records WHERE id = ?`), [id]);
// 安全特性:
// 1. SQL 已包含 is_deleted 条件 → 原样返回(避免重复加)
// 2. SQL 含 WHERE → 追加 AND alias.is_deleted = 0
// 3. SQL 无 WHERE → 在 ORDER/GROUP/LIMIT 之前注入 WHERE alias.is_deleted = 0
// 4. SQL 无 WHERE/ORDER/GROUP/LIMIT → 末尾追加
// 软删表清单:vehicles / wash_records / maintenance_records / refuel_records / charging_records / insurance_records
// ============================================================
export function softWhere(table, sql, alias) {
const a = alias || table;
// 已显式声明 is_deleted 过滤 → 跳过
if (/\bis_deleted\b/i.test(sql)) return sql;
const cond = `${a}.is_deleted = 0`;
// 已有 WHERE → 追加 AND
if (/\bWHERE\b/i.test(sql)) {
return sql.replace(/\bWHERE\b/i, `WHERE ${cond} AND`);
}
// 在 ORDER / GROUP / LIMIT 之前插入 WHERE
const m = sql.match(/\b(ORDER\s+BY|GROUP\s+BY|LIMIT)\b/i);
if (m) {
const idx = m.index;
return sql.slice(0, idx) + `WHERE ${cond} ` + sql.slice(idx);
}
// 无任何子句 → 末尾追加
const trimmed = sql.replace(/;\s*$/, '');
return trimmed + (trimmed.endsWith(' ') ? '' : ' ') + `WHERE ${cond}`;
}
+41
View File
@@ -0,0 +1,41 @@
// server/src/http.js — 50 行 curl wrapper (get/post/put/delete)
import { request } from 'undici';
const DEFAULT_TIMEOUT = 15000;
/**
* @param {string} url
* @param {object} [opts]
* @param {object} [opts.body] JSON body
* @param {object} [opts.headers]
* @param {string} [opts.method] GET/POST/PUT/DELETE
* @param {number} [opts.timeout]
*/
export async function http(url, opts = {}) {
const { body, headers = {}, method = 'GET', timeout = DEFAULT_TIMEOUT } = opts;
const init = { method, headers: { 'User-Agent': 'carwash-system/2.0', ...headers }, bodyTimeout: timeout, headersTimeout: timeout };
if (body !== undefined) {
if (typeof body === 'string' || Buffer.isBuffer(body)) {
init.body = body;
} else {
init.body = JSON.stringify(body);
init.headers['Content-Type'] = init.headers['Content-Type'] || 'application/json';
}
}
const { statusCode, body: resBody } = await request(url, init);
const text = await resBody.text();
let data;
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
if (statusCode >= 400) {
const err = new Error(`HTTP ${statusCode} ${url}: ${typeof data === 'string' ? data : JSON.stringify(data)}`);
err.status = statusCode;
err.body = data;
throw err;
}
return data;
}
export const httpGet = (url, opts = {}) => http(url, { ...opts, method: 'GET' });
export const httpPost = (url, body, opts = {}) => http(url, { ...opts, method: 'POST', body });
export const httpPut = (url, body, opts = {}) => http(url, { ...opts, method: 'PUT', body });
export const httpDelete = (url, opts = {}) => http(url, { ...opts, method: 'DELETE' });
+212
View File
@@ -0,0 +1,212 @@
// server/src/index.js — Express app 入口
import express from 'express';
import session from 'express-session';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import path from 'node:path';
import fs from 'node:fs';
import url from 'node:url';
import { loadConfig, loadDotenv } from './config.js';
import { initDb, migrate, db } from './db.js';
import { requireAuth } from './middleware/auth.js';
import { requireCsrf } from './middleware/csrf.js';
import { ipRateLimit } from './middleware/ipRateLimit.js';
import authRoutes from './routes/auth.js';
import washesRoutes from './routes/washes.js';
import chemicalsRoutes from './routes/chemicals.js';
import vehiclesRoutes from './routes/vehicles.js';
import settingsRoutes from './routes/settings.js';
import insuranceRoutes from './routes/insurance.js';
import aiRoutes from './routes/ai.js';
import { maintRouter, refuelRouter, chargingRouter } from './routes/logs.js';
import operationLogsRoutes from './routes/operationLogs.js';
import extraRoutes from './routes/extra.js';
import notifRoutes from './routes/notifications.js';
import tagRoutes from './routes/tags.js';
import achRoutes from './routes/achievements.js';
import { grocyGet } from './services/grocyClient.js';
import setupRouter from './setup.js';
import { mountSwagger } from './swagger.js';
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
loadDotenv();
const SETUP_DONE_FILE = path.join(__dirname, '../../.setup_done');
export const isSetupDone = fs.existsSync(SETUP_DONE_FILE);
// 防止未捕获的 Promise 异常 / 同步异常把整个 Node 进程拉下水:
// Express 4 不会自动捕获 async handler 的 reject,导致一次 bug 直接 500 后进程退出。
// 这里把异常落日志但保持进程存活,配合下面的 error middleware 至少能给客户端返回 500 JSON。
process.on('unhandledRejection', (reason) => {
console.error('[server] unhandledRejection:', reason);
});
process.on('uncaughtException', (err) => {
console.error('[server] uncaughtException:', err);
});
let config = {};
if (isSetupDone) {
await initDb();
await migrate({ verbose: true });
config = await loadConfig();
}
// ============================================================
// Express app
// ============================================================
const app = express();
app.set('trust proxy', 1);
app.use(cors({ origin: (origin, cb) => cb(null, true), credentials: true }));
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
app.use(cookieParser());
// 安装向导(始终挂载)
app.use(setupRouter);
// 未初始化:所有请求重定向 /setupSPA fallback 也指向 /setup
if (!isSetupDone) {
app.use((req, res, next) => {
if (req.path.startsWith('/api/setup') || req.path === '/setup') return next();
res.redirect('/setup');
});
const clientDist = path.join(__dirname, '../../client/dist');
if (fs.existsSync(clientDist)) {
app.use(express.static(clientDist));
app.get(/^(?!\/api).*/, (req, res) => {
if (req.path.startsWith('/api/')) return res.status(404).json({ ok: false });
res.sendFile(path.join(clientDist, 'index.html'));
});
}
} else {
// ============================================================
// 正常模式
// ============================================================
const cookieSecure = config.session.cookie_secure === 'true' || (config.session.cookie_secure === 'auto' && process.env.NODE_ENV === 'production');
app.use(session({
name: 'CARWASH_SID',
secret: process.env.SESSION_SECRET || 'carwash-change-me-in-prod-' + (config.app.env || 'dev'),
resave: false,
saveUninitialized: false,
rolling: true,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: cookieSecure,
maxAge: config.session.lifetime_days * 86400 * 1000,
},
}));
app.locals.config = config;
// 公开路由
app.use(authRoutes);
// Swagger / OpenAPI 文档(公开)
mountSwagger(app);
// 健康检查:
// /api/health — 简版(兼容旧调用)
// /api/health/live — 进程活着就返 200k8s livenessProbe
// /api/health/ready — DB 连得上才返 200k8s readinessProbe / 宝塔监控)
/**
* @openapi
* /api/health:
* get:
* tags: [health]
* summary: 简版健康检查(兼容旧调用)
* security: []
* responses: { 200: { description: OK } }
*/
app.get('/api/health', (req, res) => res.json({ ok: true, data: { status: 'ok', time: new Date().toISOString() } }));
/**
* @openapi
* /api/health/live:
* get:
* tags: [health]
* summary: livenessProbe — 进程活着就返 200
* security: []
* responses: { 200: { description: live } }
*/
app.get('/api/health/live', (req, res) => res.status(200).json({ ok: true, data: { status: 'live' } }));
/**
* @openapi
* /api/health/ready:
* get:
* tags: [health]
* summary: readinessProbe — DB 连得上才返 200,否则 503
* security: []
* responses:
* 200: { description: ready }
* 503: { description: DB 不可用 }
*/
app.get('/api/health/ready', async (req, res) => {
try {
await db().get('SELECT 1 AS ok');
res.json({ ok: true, data: { status: 'ready', db: 'up' } });
} catch (e) {
res.status(503).json({ ok: false, error: { code: 'NOT_READY', message: e.message } });
}
});
// 公开 Grocy 字典
app.get('/api/objects/quantity_units', async (req, res) => {
try {
const cfg = await loadConfig();
const data = await grocyGet(cfg, 'api/objects/quantity_units', { timeout: 10000 });
res.json(data);
} catch (e) {
res.status(500).json({ ok: false, error: { code: 'GROCY_ERR', message: e.message } });
}
});
// 需登录 API
app.use('/api', requireAuth);
app.use('/api', extraRoutes);
app.use('/api', notifRoutes);
app.use('/api', tagRoutes);
app.use('/api', achRoutes);
// CSRF 校验:对所有非 GET 请求强制检查 X-CSRF-Token
// - 前端 axios interceptor 会自动带;token 过期会被拦截器自动 refresh + 重试
// - /api/auth/* 路由内部有自己更细致的 csrf 校验(含过期判定),这里跳过避免双重校验
app.use('/api', (req, res, next) => {
if (req.path.startsWith('/auth/')) return next();
return requireCsrf(req, res, next);
});
app.use('/api', washesRoutes);
app.use('/api', chemicalsRoutes);
app.use('/api', vehiclesRoutes);
app.use('/api', settingsRoutes);
app.use('/api', maintRouter);
app.use('/api', refuelRouter);
app.use('/api', chargingRouter);
app.use('/api', insuranceRoutes);
app.use('/api', aiRoutes);
app.use('/api', operationLogsRoutes);
// 附件
const uploadsDir = path.join(__dirname, '../../uploads');
app.use('/api/uploads', express.static(uploadsDir, {
setHeaders: (res) => { res.setHeader('Content-Disposition', 'inline'); },
}));
// SPA
const clientDist = path.join(__dirname, '../../client/dist');
if (fs.existsSync(clientDist)) {
app.use(express.static(clientDist));
app.get(/^(?!\/api).*/, (req, res, next) => {
if (req.path.startsWith('/api/')) return next();
const indexFile = path.join(clientDist, 'index.html');
if (fs.existsSync(indexFile)) return res.sendFile(indexFile);
res.status(404).send('Vue app not built. Run: cd client && npm run build');
});
}
// 错误处理
app.use((err, req, res, next) => {
console.error('[server] error:', err);
if (err.type === 'entity.parse.failed') return res.status(400).json({ ok: false, error: { code: 'BAD_JSON' } });
res.status(500).json({ ok: false, error: { code: 'INTERNAL', message: err.message } });
});
}
export { app };
+9
View File
@@ -0,0 +1,9 @@
// server/src/middleware/auth.js — 全站登录保护
export function requireAuth(req, res, next) {
if (req.session && req.session.userId) return next();
if (req.path.startsWith('/api/')) {
return res.status(401).json({ ok: false, error: { code: 'UNAUTHORIZED', message: '请先登录' } });
}
const returnTo = encodeURIComponent(req.originalUrl || '/');
return res.redirect(`/login?return_to=${returnTo}`);
}
+15
View File
@@ -0,0 +1,15 @@
// server/src/middleware/csrf.js — CSRF 校验
import { timingSafeEqual } from 'node:crypto';
export function requireCsrf(req, res, next) {
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
const token = (req.body && req.body.csrf_token) || req.headers['x-csrf-token'];
if (!req.session?.csrfToken) {
return res.status(403).json({ ok: false, error: { code: 'CSRF', message: '请先获取 CSRF token' } });
}
const a = Buffer.from(req.session.csrfToken);
const b = Buffer.from(String(token || ''));
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(403).json({ ok: false, error: { code: 'CSRF', message: 'CSRF token 校验失败' } });
}
next();
}
+51
View File
@@ -0,0 +1,51 @@
// server/src/middleware/ipRateLimit.js — 通用 IP 限流(内存版,单进程够用)
// 用法:app.use('/api/ai', ipRateLimit({ windowMs: 60_000, max: 10, name: 'ai' }));
// 命中时返 429 + Retry-After,不写 DB(防撞库那套是 DB 版,这里只防误触/打爆 MySQL)
const buckets = new Map(); // key -> { count, resetAt }
function getKey(req, name) {
// 用 X-Forwarded-For 第一跳(Vite 代理会塞),fallback 到 socket 地址
const xff = (req.headers['x-forwarded-for'] || '').split(',')[0]?.trim();
const ip = xff || req.socket?.remoteAddress || req.ip || '0.0.0.0';
return `${name}:${ip}`;
}
export function ipRateLimit({ windowMs = 60_000, max = 10, name = 'rl' } = {}) {
return function (req, res, next) {
// 定时清理,避免 Map 无限增长
if (buckets.size > 5000) {
const now = Date.now();
for (const [k, v] of buckets) if (v.resetAt < now) buckets.delete(k);
}
const key = getKey(req, name);
const now = Date.now();
const b = buckets.get(key);
if (!b || b.resetAt < now) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
return next();
}
b.count++;
if (b.count > max) {
const retryAfter = Math.max(1, Math.ceil((b.resetAt - now) / 1000));
res.set('Retry-After', String(retryAfter));
res.set('X-RateLimit-Limit', String(max));
res.set('X-RateLimit-Remaining', '0');
res.set('X-RateLimit-Reset', String(Math.ceil(b.resetAt / 1000)));
return res.status(429).json({
ok: false,
error: {
code: 'RATE_LIMITED',
message: `请求过于频繁,请 ${retryAfter} 秒后再试`,
retry_after: retryAfter,
},
});
}
res.set('X-RateLimit-Limit', String(max));
res.set('X-RateLimit-Remaining', String(max - b.count));
next();
};
}
// 给测试用的清理函数
export function _clearBuckets() { buckets.clear(); }
+137
View File
@@ -0,0 +1,137 @@
// server/src/routes/achievements.js — 成就系统
import { Router } from 'express';
import { db } from '../db.js';
const router = Router();
function ok(res, data) { res.json({ ok: true, data }); }
function fail(res, status, code, message) {
res.status(status).json({ ok: false, error: { code, message } });
}
// 拿到 user id(从 session,没登录则按 1 处理)
function getUserId(req) {
return req.session?.user?.id || req.user?.id || 1;
}
// 计算累计指标
async function computeStats() {
const washCount = (await db().get('SELECT COUNT(*) AS n FROM wash_records WHERE is_deleted = 0'))?.n || 0;
const refuelCount = (await db().get('SELECT COUNT(*) AS n FROM refuel_records WHERE is_deleted = 0'))?.n || 0;
const maintCount = (await db().get('SELECT COUNT(*) AS n FROM maintenance_records WHERE is_deleted = 0'))?.n || 0;
const insCount = (await db().get('SELECT COUNT(*) AS n FROM insurance_records WHERE is_deleted = 0'))?.n || 0;
// 累计里程:MAX(odometer) - MIN(odometer) 之和,按车辆分组后相加
const kmRow = await db().get(`
SELECT COALESCE(SUM(GREATEST(0, mx - mn)), 0) AS total_km FROM (
SELECT
vehicle_id,
MIN(odometer_km) AS mn,
MAX(odometer_km) AS mx
FROM (
SELECT vehicle_id, odometer_km FROM refuel_records WHERE is_deleted = 0 AND vehicle_id IS NOT NULL AND odometer_km > 0
UNION ALL
SELECT vehicle_id, odometer_km FROM charging_records WHERE is_deleted = 0 AND vehicle_id IS NOT NULL AND odometer_km > 0
UNION ALL
SELECT vehicle_id, odometer_km FROM maintenance_records WHERE is_deleted = 0 AND vehicle_id IS NOT NULL AND odometer_km > 0
) t
GROUP BY vehicle_id
) v
`);
// 连续洗车天数:取所有 wash_records 的日期 dedup,找最长连续
const dates = await db().all('SELECT DISTINCT wash_date FROM wash_records WHERE is_deleted = 0 ORDER BY wash_date DESC');
let longestStreak = 0, currentStreak = 0;
const dateSet = new Set(dates.map(d => d.wash_date));
if (dateSet.size > 0) {
const sorted = Array.from(dateSet).sort();
let run = 1, max = 1;
for (let i = 1; i < sorted.length; i++) {
const prev = new Date(sorted[i - 1]);
const cur = new Date(sorted[i]);
const diff = Math.round((cur - prev) / 86400000);
if (diff === 1) { run++; max = Math.max(max, run); }
else { run = 1; }
}
longestStreak = max;
// current streak:从今天倒推
let c = 0;
const t = new Date();
while (dateSet.has(t.toISOString().slice(0, 10))) {
c++;
t.setDate(t.getDate() - 1);
}
currentStreak = c;
}
// cost_track_30d: 连续 30 天有任意记录
let trackDays = 0;
{
const any = new Set();
const sources = [
await db().all('SELECT wash_date AS d FROM wash_records WHERE is_deleted = 0'),
await db().all('SELECT refuel_date AS d FROM refuel_records WHERE is_deleted = 0'),
await db().all('SELECT charge_date AS d FROM charging_records WHERE is_deleted = 0'),
await db().all('SELECT maint_date AS d FROM maintenance_records WHERE is_deleted = 0'),
];
for (const s of sources) for (const r of s) if (r.d) any.add(r.d);
let c = 0;
const t = new Date();
while (any.has(t.toISOString().slice(0, 10))) { c++; t.setDate(t.getDate() - 1); }
trackDays = c;
}
return {
washCount, refuelCount, maintCount, insuranceCount: insCount,
totalKm: Number(kmRow?.total_km || 0),
longestStreak, currentStreak, trackDays,
};
}
// 列表 + 当前解锁状态
router.get('/achievements', async (req, res) => {
try {
const uid = getUserId(req);
const stats = await computeStats();
const items = await db().all('SELECT id, code, name, description, icon, threshold FROM achievements ORDER BY threshold ASC, id ASC');
const userAch = await db().all('SELECT id, achievement_id, progress, unlocked_at FROM user_achievements WHERE user_id = ?', [uid]);
const ua = {};
for (const u of userAch) ua[u.achievement_id] = u;
const get = (k) => {
switch (k) {
case 'wash_first': case 'wash_10': case 'wash_50': case 'wash_100': return stats.washCount;
case 'wash_streak_7': return Math.max(stats.longestStreak, stats.currentStreak);
case 'wash_streak_30': return Math.max(stats.longestStreak, stats.currentStreak);
case 'refuel_10': case 'refuel_50': return stats.refuelCount;
case 'maintain_first': case 'maintain_5': return stats.maintCount;
case 'mileage_10000': case 'mileage_100000': return stats.totalKm;
case 'cost_track_30d': return stats.trackDays;
case 'insure_first': return stats.insuranceCount;
default: return 0;
}
};
const unlocked = [];
for (const a of items) {
const progress = get(a.code);
const existing = ua[a.id];
const isUnlocked = progress >= a.threshold;
if (isUnlocked && !existing?.unlocked_at) {
await db().run('INSERT INTO user_achievements (user_id, achievement_id, progress, unlocked_at) VALUES (?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE progress = ?, unlocked_at = NOW()', [uid, a.id, progress, progress]);
} else if (existing) {
// 用 user_achievements.id (SELECT 里加了 id 字段) 更新 progress
await db().run('UPDATE user_achievements SET progress = ? WHERE id = ?', [progress, existing.id]);
}
unlocked.push({
...a,
progress,
unlocked: isUnlocked,
unlocked_at: isUnlocked ? (existing?.unlocked_at || new Date().toISOString().slice(0, 19).replace('T', ' ')) : null,
});
}
const summary = {
total: unlocked.length,
unlocked: unlocked.filter(a => a.unlocked).length,
progress: unlocked.length > 0 ? Math.round((unlocked.filter(a => a.unlocked).length / unlocked.length) * 100) : 0,
};
ok(res, { summary, stats, items: unlocked });
} catch (e) {
fail(res, 500, 'ACH_ERR', e.message);
}
});
export default router;
+183
View File
@@ -0,0 +1,183 @@
// server/src/routes/ai.js — AI 截图识别(上传 + 识别 + 配置)
import { Router } from 'express';
import { db } from '../db.js';
import { recognizeImage, TYPES, getAiConfig } from '../services/aiVision.js';
import { ipRateLimit } from '../middleware/ipRateLimit.js';
import multer from 'multer';
import path from 'node:path';
import fs from 'node:fs';
import url from 'node:url';
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const UPLOAD_DIR = path.join(__dirname, '../../../uploads/ai');
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
// AI 识别是重活(要打外部多模态 API),每个 IP 每分钟最多 10 次,
// 防止前端 bug 死循环把外部配额 / 钱打爆。
const aiRateLimit = ipRateLimit({ windowMs: 60_000, max: 10, name: 'ai' });
const router = Router();
function ok(res, data) { res.json(data); }
function fail(res, status, code, message, extra) {
res.status(status).json({ ok: false, error: { code, message, ...(extra || {}) } });
}
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
filename: (req, file, cb) => {
const ts = Date.now(), rand = Math.random().toString(36).slice(2, 8);
const ext = path.extname(file.originalname).toLowerCase() || '.png';
cb(null, `ai-${ts}-${rand}${ext}`);
},
});
const ALLOWED_MIMES = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/heic', 'image/heif']);
const upload = multer({ storage, limits: { fileSize: 8 * 1024 * 1024 },
fileFilter: (req, file, cb) => { if (ALLOWED_MIMES.has(file.mimetype)) cb(null, true); else cb(null, false); },
});
// POST /api/ai/upload — 上传图片,返回 image_id
/**
* @openapi
* /api/ai/upload:
* post:
* tags: [ai]
* summary: 上传图片(OCR 前置)
* /api/ai/recognize:
* post:
* tags: [ai]
* summary: AI 截图识别(5 种类型:wash/refuel/charge/maint/insurance
* /api/ai/config:
* get:
* tags: [ai]
* summary: 读 AI 配置
* post:
* tags: [ai]
* summary: 写 AI 配置
* /api/ai/test:
* post:
* tags: [ai]
* summary: 测试 AI 连接
*/
router.post('/ai/upload', upload.single('file'), async (req, res) => {
if (!req.file) return fail(res, 422, 'BAD_IMAGE', '请上传图片(jpg/png/webp/heic),最大 8MB');
const relPath = path.relative(path.join(__dirname, '../../..'), req.file.path).replace(/\\/g, '/');
ok(res, { image_id: req.file.filename, path: relPath, url: `/api/${relPath}`, name: req.file.originalname, size: req.file.size, mime: req.file.mimetype });
});
// POST /api/ai/recognize — body: { image_id, type }
router.post('/ai/recognize', aiRateLimit, async (req, res) => {
const b = req.body || {};
if (!b.image_id) return fail(res, 422, 'VALIDATION', 'image_id 必填');
if (!TYPES.includes(b.type)) return fail(res, 422, 'VALIDATION', `type 必填且为 ${TYPES.join('/')}`);
const filePath = path.join(UPLOAD_DIR, b.image_id);
if (!fs.existsSync(filePath)) return fail(res, 404, 'NOT_FOUND', '图片不存在或已过期');
try {
const r = await recognizeImage(filePath, b.type);
ok(res, { type: b.type, data: r.data, raw: r.raw, model: r.model, usage: r.usage });
} catch (e) { fail(res, 500, 'AI_ERR', e.message); }
});
// GET /api/ai/config — 读 AI 配置(key 脱敏)
router.get('/ai/config', async (req, res) => {
const cfg = await getAiConfig();
ok(res, {
provider: cfg.provider,
provider_url: cfg.provider_url,
has_api_key: !!cfg.api_key,
api_key_hint: cfg.api_key ? cfg.api_key.slice(0, 4) + '••••' + cfg.api_key.slice(-4) : '',
model: cfg.model,
enabled: cfg.enabled,
types: TYPES,
providers: [
{ id: 'openai_compat', name: 'OpenAI 兼容(OpenAI / Kimi / DeepSeek / 硅基流动)', url: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
{ id: 'minimax_vl', name: 'MiniMax M3 多模态', url: 'https://api.minimaxi.com/v1', model: 'MiniMax-M3' },
],
});
});
// POST /api/ai/config body: { provider?, provider_url, api_key, model, enabled }
router.post('/ai/config', async (req, res) => {
const b = req.body || {};
const updates = [];
if (b.provider !== undefined) updates.push({ key: 'ai_provider', value: String(b.provider).trim() });
if (b.provider_url !== undefined) updates.push({ key: 'ai_provider_url', value: String(b.provider_url).trim() });
if (b.api_key !== undefined) updates.push({ key: 'ai_api_key', value: String(b.api_key).trim() });
if (b.model !== undefined) updates.push({ key: 'ai_model', value: String(b.model).trim() });
if (b.enabled !== undefined) updates.push({ key: 'ai_enabled', value: b.enabled ? '1' : '0' });
if (updates.length === 0) return fail(res, 422, 'VALIDATION', '无有效字段');
for (const u of updates) {
await db().run(`INSERT INTO settings (\`key\`, value, is_secret, description, updated_at)
VALUES (?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = NOW()`,
[u.key, u.value, u.key === 'ai_api_key' ? 1 : 0, 'AI Vision config']);
}
ok(res, { updated: updates.length });
});
// POST /api/ai/test body: { provider?, provider_url?, api_key?, model? }
router.post('/ai/test', aiRateLimit, async (req, res) => {
const cfg = await getAiConfig();
const b = req.body || {};
// 临时合并(不写入):用于测试用户填的但还没保存的值
const provider = b.provider || cfg.provider;
const provider_url = (b.provider_url || cfg.provider_url || '').replace(/\/+$/, '');
const api_key = b.api_key || cfg.api_key;
const model = b.model || cfg.model || (provider === 'minimax_vl' ? 'MiniMax-M3' : 'gpt-4o-mini');
if (!provider_url) return fail(res, 422, 'VALIDATION', '请填 provider_url');
if (!api_key) return fail(res, 422, 'VALIDATION', '请填 api_key');
if (!model) return fail(res, 422, 'VALIDATION', '请填 model');
// 端点:所有 provider 都用标准 /chat/completions
// MiniMax M3 原生多模态走 OpenAI 兼容 /chat/completions
const endpoint = provider_url + '/chat/completions';
// 测试图:从 uploads/ai/ 里挑最新的真实图片(>500B,MiniMax 内容审查对 1×1 透明 PNG 会判敏感)。
// 用户可以自己先在「洗车/加油/充电/保养/保单」任一页面点 AI 截图识别上传一张真实小票图,
// 然后再点测试连接 — 这样测试用的就是用户自己上传的真实图。
let testImgUrl = null;
try {
const dir = UPLOAD_DIR;
const candidates = fs.readdirSync(dir)
.filter(f => /\.(jpe?g|png|webp)$/i.test(f))
.map(f => {
const p = path.join(dir, f);
const st = fs.statSync(p);
return { f, p, s: st.size, m: st.mtimeMs };
})
.filter(x => x.s > 500) // 跳过 1×1 透明 PNG(68 字节那种)
.sort((a, b) => b.m - a.m); // mtime 倒序:最新的优先
if (candidates.length) {
const pick = candidates[0];
const ext = path.extname(pick.f).slice(1).toLowerCase();
const mime = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`;
const buf = fs.readFileSync(pick.p);
testImgUrl = `data:${mime};base64,${buf.toString('base64')}`;
}
} catch (_) { /* fallback to error below */ }
if (!testImgUrl) {
return fail(res, 422, 'NO_TEST_IMAGE', 'uploads/ai/ 里没有可用的测试图(>500B)。请先在任一记录页用 AI 截图识别上传一张真实小票图,再点测试连接');
}
try {
const reqBody = {
model, max_tokens: 20,
messages: [{ role: 'user', content: [
{ type: 'image_url', image_url: { url: testImgUrl } },
{ type: 'text', text: '回复 OK' },
] }],
};
// MiniMax M3 默认开启 thinking 会污染 reply,关掉
if (provider === 'minimax_vl') {
reqBody.thinking = { type: 'disabled' };
}
const r = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${api_key}` },
body: JSON.stringify(reqBody),
signal: AbortSignal.timeout(20_000),
});
if (!r.ok) { const errText = await r.text(); return fail(res, 502, 'AI_API_ERR', `AI API 返 ${r.status}: ${errText.slice(0, 300)}`); }
const j = await r.json();
ok(res, { ok: true, provider, model: j.model || model, reply: (j.choices?.[0]?.message?.content || '').trim() });
} catch (e) { fail(res, 502, 'AI_CONN_ERR', e.message); }
});
export default router;
+304
View File
@@ -0,0 +1,304 @@
// server/src/routes/auth.js — JSON 版认证端点(/api/auth/*),给 SPA 使用
import { Router } from 'express';
import {
findUser,
findUserById,
verifyPassword,
loginSuccess,
csrfToken,
verifyCsrf,
setSession,
clearSession,
changePassword,
changeUsername,
listUsers,
createUser,
userExists,
deleteUser,
setActive,
} from '../services/auth.js';
import {
isLocked,
recordFailure,
recordSuccess,
recentFailuresByIp,
recentFailuresByUsername,
} from '../services/rateLimit.js';
const router = Router();
function clientIp(req) {
const candidates = [
req.headers['cf-connecting-ip'],
req.headers['x-real-ip'],
(req.headers['x-forwarded-for'] || '').split(',')[0].trim(),
req.ip,
].filter(Boolean);
for (const c of candidates) if (/^\d+\.\d+\.\d+\.\d+$|^[0-9a-f:]+$/i.test(c)) return c;
return '0.0.0.0';
}
router.get('/login', (req, res) => {
if (req.session?.userId) return res.redirect('/');
res.json({
ok: true,
data: {
csrf_token: csrfToken(req),
error: req.query.error || null,
return_to: req.query.return_to || '/',
locked_until: req.query.locked_until || null,
},
});
});
router.post('/login', async (req, res) => {
try {
const { username, password, csrf_token, return_to } = req.body || {};
if (!verifyCsrf(req, csrf_token))
return res.status(403).json({ ok: false, error: { code: 'CSRF', message: '会话过期' } });
if (!username || !password) {
return res.redirect('/login?error=empty&return_to=' + encodeURIComponent(return_to || '/'));
}
const ip = clientIp(req);
const ua = req.headers['user-agent'] || '';
const locks = await isLocked(ip, username);
if (locks.ip) {
await recordFailure(ip, username, ua, 'locked', req.app.locals.config.auth);
return res.redirect(`/login?error=ip_locked&locked_until=${encodeURIComponent(locks.ip.until)}`);
}
if (locks.user) {
await recordFailure(ip, username, ua, 'locked', req.app.locals.config.auth);
return res.redirect(`/login?error=user_locked&locked_until=${encodeURIComponent(locks.user.until)}`);
}
const user = await verifyPassword(username, password);
if (!user) {
await recordFailure(
ip,
username,
ua,
(await userExists(username)) ? 'wrong_password' : 'no_such_user',
req.app.locals.config.auth
);
return res.redirect('/login?error=bad_credentials&return_to=' + encodeURIComponent(return_to || '/'));
}
if (!user.is_active) {
await recordFailure(ip, username, ua, 'inactive', req.app.locals.config.auth);
return res.redirect('/login?error=inactive&return_to=' + encodeURIComponent(return_to || '/'));
}
await recordSuccess(ip, username, ua);
await loginSuccess(user.id, ip);
setSession(req, user.id, user.username);
const target = return_to && return_to.startsWith('/') ? return_to : '/';
res.redirect(target);
} catch (e) {
console.error('[login] error:', e);
res.status(500).json({ ok: false, error: { code: 'INTERNAL', message: '服务器错误:' + e.message } });
}
});
router.post('/api/auth/logout', (req, res) => {
const token = (req.body && req.body.csrf_token) || req.headers['x-csrf-token'];
if (!verifyCsrf(req, token))
return res.status(403).json({ ok: false, error: { code: 'CSRF', message: '请通过正常表单退出' } });
clearSession(req);
res.json({ ok: true });
});
router.post('/api/auth/account', async (req, res) => {
if (!req.session?.userId) return res.status(401).json({ ok: false, error: { code: 'UNAUTHORIZED' } });
const { csrf_token, new_username, current_password, new_password } = req.body || {};
if (!verifyCsrf(req, csrf_token)) {
return res.status(403).json({ ok: false, error: { code: 'CSRF', message: 'CSRF token 校验失败' } });
}
const user = await findUserById(req.session.userId);
if (!user) return res.status(401).json({ ok: false, error: { code: 'UNAUTHORIZED' } });
const fullUser = await findUser(user.username);
if (!fullUser || !(await verifyPassword(user.username, current_password || ''))) {
return res.status(403).json({ ok: false, error: { code: 'WRONG_CURRENT', message: '当前密码不正确' } });
}
let changed = false;
try {
if (new_username && new_username !== user.username) {
await changeUsername(user.id, new_username);
changed = true;
}
if (new_password) {
if (new_password.length < 8)
return res.status(400).json({ ok: false, error: { code: 'TOO_SHORT', message: '新密码至少 8 位' } });
await changePassword(user.id, new_password);
changed = true;
}
} catch (e) {
return res.status(500).json({ ok: false, error: { code: 'INTERNAL', message: e.message } });
}
if (!changed) return res.status(400).json({ ok: false, error: { code: 'NO_CHANGE', message: '未做任何修改' } });
res.json({ ok: true, data: { changed } });
});
router.get('/api/auth/me', async (req, res) => {
if (!req.session?.userId) return res.status(401).json({ ok: false, error: { code: 'UNAUTHORIZED' } });
const u = await findUserById(req.session.userId);
res.json({ ok: true, data: u });
});
router.get('/api/auth/csrf', (req, res) => {
res.json({ ok: true, data: { csrf_token: csrfToken(req) } });
});
/**
* @openapi
* /api/auth/login:
* post:
* tags: [auth]
* summary: 登录(返回 csrf token + session cookie
* security: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [username, password, csrf_token]
* properties:
* username: { type: string, example: admin }
* password: { type: string, example: carwash2026 }
* csrf_token: { type: string }
* return_to: { type: string, description: '登录成功后跳转的相对路径' }
* responses:
* 200: { description: OK, content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, data: { type: object, properties: { csrf_token: { type: string } } } } } } } }
* 401: { description: 用户名或密码错误 }
* 423: { description: 账号被锁定(密码错太多次) }
*/
router.post('/api/auth/login', async (req, res) => {
try {
const { username, password } = req.body || {};
const token = (req.body && req.body.csrf_token) || req.headers['x-csrf-token'];
if (!verifyCsrf(req, token))
return res.status(403).json({ ok: false, error: { code: 'CSRF', message: '会话过期' } });
if (!username || !password) {
return res.status(400).json({ ok: false, error: { code: 'EMPTY', message: '用户名/密码必填' } });
}
const ip = clientIp(req);
const ua = req.headers['user-agent'] || '';
const cfg = req.app.locals.config.auth;
const locks = await isLocked(ip, username);
if (locks.ip || locks.user) {
await recordFailure(ip, username, ua, 'locked', cfg);
const lock = locks.ip || locks.user;
const retryAfter = Math.max(1, Math.ceil((new Date(lock.until).getTime() - Date.now()) / 1000));
res.set('Retry-After', String(retryAfter));
return res.status(429).json({
ok: false,
error: {
code: 'LOCKED',
message: '登录失败次数过多,账号已锁定',
retry_after: retryAfter,
locked_until: lock.until,
lock_type: locks.ip ? 'ip' : 'user',
},
});
}
const user = await verifyPassword(username, password);
if (!user) {
await recordFailure(
ip,
username,
ua,
(await userExists(username)) ? 'wrong_password' : 'no_such_user',
cfg
);
// 错误次数(已经 +1 写入 DB 了)
const ipFails = await recentFailuresByIp(ip, cfg.login_lock_minutes_ip * 60 * 1000);
const userFails = await recentFailuresByUsername(username, cfg.login_lock_minutes_user * 60 * 1000);
const ipRemaining = Math.max(0, cfg.login_max_failures_ip - ipFails);
const userRemaining = Math.max(0, cfg.login_max_failures_user - userFails);
// 取两个中更严格的那个
const maxFails = Math.max(cfg.login_max_failures_ip, cfg.login_max_failures_user);
const currentFails = Math.max(ipFails, userFails);
const remaining = Math.max(0, maxFails - currentFails);
return res.status(401).json({
ok: false,
error: {
code: 'BAD_CREDENTIALS',
message: '用户名或密码错误',
fail_count: currentFails,
fail_max: maxFails,
fail_remaining: remaining,
ip_remaining: ipRemaining,
user_remaining: userRemaining,
lock_minutes: cfg.login_lock_minutes_user,
},
});
}
if (!user.is_active) {
await recordFailure(ip, username, ua, 'inactive', cfg);
return res.status(403).json({ ok: false, error: { code: 'INACTIVE', message: '账户已停用' } });
}
await recordSuccess(ip, username, ua);
await loginSuccess(user.id, ip);
setSession(req, user.id, user.username);
res.json({ ok: true, data: { user: { id: user.id, username: user.username } } });
} catch (e) {
console.error('[auth] /api/auth/login error:', e);
res.status(500).json({ ok: false, error: { code: 'INTERNAL', message: '服务器错误:' + e.message } });
}
});
router.post('/api/auth/logout', (req, res) => {
const token = (req.body && req.body.csrf_token) || req.headers['x-csrf-token'];
if (!verifyCsrf(req, token)) return res.status(403).json({ ok: false, error: { code: 'CSRF' } });
clearSession(req);
res.json({ ok: true, data: { logged_out: true } });
});
router.get('/api/users', async (req, res) => {
if (!req.session?.userId) return res.status(401).json({ ok: false, error: { code: 'UNAUTHORIZED' } });
try {
const users = await listUsers();
res.json({ ok: true, data: users });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
router.post('/api/users', async (req, res) => {
if (!req.session?.userId) return res.status(401).json({ ok: false, error: { code: 'UNAUTHORIZED' } });
const { csrf_token, username, password, is_admin } = req.body || {};
if (!verifyCsrf(req, csrf_token)) return res.status(403).json({ ok: false, error: { code: 'CSRF' } });
if (!username || !password) return res.status(400).json({ ok: false, error: '用户名和密码必填' });
try {
if (await userExists(username)) return res.status(409).json({ ok: false, error: '用户名已存在' });
const id = await createUser(username, password, 12);
res.json({ ok: true, data: { id, username } });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
router.delete('/api/users/:username', async (req, res) => {
if (!req.session?.userId) return res.status(401).json({ ok: false, error: { code: 'UNAUTHORIZED' } });
const { csrf_token } = req.body || {};
if (!verifyCsrf(req, csrf_token)) return res.status(403).json({ ok: false, error: { code: 'CSRF' } });
try {
const u = await userExists(req.params.username);
if (!u) return res.status(404).json({ ok: false, error: '用户不存在' });
await deleteUser(u.id);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
router.post('/api/users/:username/set-active', async (req, res) => {
if (!req.session?.userId) return res.status(401).json({ ok: false, error: { code: 'UNAUTHORIZED' } });
const { csrf_token, active } = req.body || {};
if (!verifyCsrf(req, csrf_token)) return res.status(403).json({ ok: false, error: { code: 'CSRF' } });
try {
await setActive(req.params.username, !!active);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
export default router;
+318
View File
@@ -0,0 +1,318 @@
// server/src/routes/chemicals.js — 汽车用品(Grocy 镜像 + Grocy 写入)
import { Router } from 'express';
import { db } from '../db.js';
import { pullProducts } from '../services/grocyProducts.js';
import { resolveCategory, getCategoryMap, invalidateCategoryMap } from '../services/categoryMap.js';
import { grocyGet } from '../services/grocyClient.js';
import { createGrocyProduct, addGrocyStock, consumeGrocyStock, inventoryGrocyStock } from '../services/grocyWrite.js';
import { loadConfig } from '../config.js';
import { ipRateLimit } from '../middleware/ipRateLimit.js';
const router = Router();
// sync 类是重活(90s 拉全量 products),每 IP 每分钟最多 10 次
// 防止前端 bug 触发死循环把 MySQL 打爆
const syncRateLimit = ipRateLimit({ windowMs: 60_000, max: 10, name: 'sync' });
function ok(res, data) { res.json(data); }
function fail(res, status, code, message, extra) {
res.status(status).json({ ok: false, error: { code, message, ...(extra || {}) } });
}
// 把 chemicals 行增强:分类显示名 + 低库存
function enrich(row) {
if (!row) return row;
// 分类显示名优先级:本地映射 > Grocy 真实分组名(已存在 row.category> group-id 兜底
const mapped = resolveCategory(row.product_group_id);
const fromGrocy = row.category && !row.category.startsWith('group-') ? row.category : '';
const category_display = (mapped && !mapped.startsWith('group-')) ? mapped : (fromGrocy || mapped);
return {
...row,
category_display,
low_stock: row.min_stock_amount > 0 && row.current_amount <= row.min_stock_amount,
};
}
// GET /api/chemicals — 列表
/**
* @openapi
* /api/chemicals:
* get:
* tags: [chemicals]
* summary: 列出化学品库存
* post:
* tags: [chemicals]
* summary: 新建化学品
* /api/chemicals/sync:
* post:
* tags: [chemicals]
* summary: 从 Grocy 同步产品
* /api/chemicals/{id}:
* get: { tags: [chemicals], summary: 化学品详情 }
* put: { tags: [chemicals], summary: 更新化学品 }
* /api/chemicals/{id}/consume:
* post:
* tags: [chemicals]
* summary: 扣减库存(洗车消耗)
*/
router.get('/chemicals', async (req, res) => {
const rows = await db().all(`
SELECT c.*,
COALESCE(s.usage_count, 0) AS usage_count,
COALESCE(s.total_amount, 0) AS total_amount
FROM chemicals c
LEFT JOIN (
SELECT chemical_id, COUNT(*) AS usage_count, SUM(amount) AS total_amount
FROM chemical_usage GROUP BY chemical_id
) s ON s.chemical_id = c.grocy_product_id
WHERE c.is_active = 1 OR ? = 1
ORDER BY c.source DESC, c.product_group_id, c.name
`, [req.query.all ? 1 : 0]);
ok(res, rows.map(enrich));
});
// GET /api/chemicals/list — 简化版(下拉用)
router.get('/chemicals/list', async (req, res) => {
const rows = await db().all(`SELECT grocy_product_id, name, unit, category, current_amount, source
FROM chemicals WHERE is_active = 1 ORDER BY name`);
ok(res, rows.map(enrich));
});
// GET /api/chemicals/grocy-search?q=xxx — 在 Grocy 端模糊搜索
router.get('/chemicals/grocy-search', syncRateLimit, async (req, res) => {
try {
const cfg = await loadConfig();
if (!cfg.grocy.url) return fail(res, 400, 'NO_GROCY', '未配置 GROCY_URL');
const q = (req.query.q || '').trim().toLowerCase();
if (!q) return fail(res, 400, 'NO_QUERY', 'q 参数必填');
const all = await grocyGet(cfg, 'api/objects/products', { timeout: 30000 });
const items = (Array.isArray(all) ? all : []).filter(p =>
(p.name || '').toLowerCase().includes(q) ||
(p.description || '').toLowerCase().includes(q) ||
String(p.id) === q
).slice(0, 50);
ok(res, { items, total: items.length, query: q });
} catch (e) { fail(res, 500, 'SEARCH_FAIL', e.message); }
});
router.get('/chemicals/categories', async (req, res) => {
const mapped = await getCategoryMap();
// 兜底:从 chemicals.category 拿 Grocy 真实分组名(sync 时已写入)
const rows = await db().all(`
SELECT product_group_id, MAX(category) AS grocy_name
FROM chemicals
WHERE product_group_id IS NOT NULL
GROUP BY product_group_id
`);
const grocyName = {};
for (const r of rows) {
if (r.grocy_name && !String(r.grocy_name).startsWith('group-')) {
grocyName[Number(r.product_group_id)] = r.grocy_name;
}
}
const dbIds = rows.map(r => r.product_group_id);
const all = new Set([...Object.keys(mapped).map(Number), ...dbIds]);
const list = [...all].sort((a, b) => a - b).map(id => {
const local = mapped[id] && !mapped[id].startsWith('group-') ? mapped[id] : null;
return { id, name: local || grocyName[id] || `group-${id}`, is_mapped: !!local };
});
ok(res, list);
});
// GET /api/chemicals/category-mappings — 当前映射
router.get('/chemicals/category-mappings', async (req, res) => {
const fromTable = await db().all(`SELECT grocy_group_id, display_name FROM category_mappings ORDER BY grocy_group_id`);
const fromSettings = await db().get('SELECT value FROM settings WHERE `key` = ?', ['grocy_categories_json']);
let settingsArr = [];
try { settingsArr = JSON.parse(fromSettings?.value || '[]'); } catch {}
const combined = await getCategoryMap();
ok(res, { table: fromTable, settings: settingsArr, combined });
});
// POST /api/chemicals/category-mappings body: { mappings: [{id, name}] }
router.post('/chemicals/category-mappings', async (req, res) => {
const arr = Array.isArray(req.body?.mappings) ? req.body.mappings : [];
const cleaned = arr.filter(x => x && x.id != null && x.name)
.map(x => ({ id: Number(x.id), name: String(x.name).slice(0, 64) }));
await db().run(`INSERT INTO settings (\`key\`, value, is_secret, description)
VALUES ('grocy_categories_json', ?, 0, 'Grocy 分类映射')
ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = NOW()`,
[JSON.stringify(cleaned)]);
invalidateCategoryMap();
ok(res, { saved: cleaned.length });
});
// DELETE /api/chemicals/category-mappings/:id — 删单条
router.delete('/chemicals/category-mappings/:id', async (req, res) => {
const id = Number(req.params.id);
const row = await db().get('SELECT value FROM settings WHERE `key` = ?', ['grocy_categories_json']);
let arr = [];
try { arr = JSON.parse(row?.value || '[]'); } catch {}
arr = arr.filter(x => Number(x.id) !== id);
await db().run(`INSERT INTO settings (\`key\`, value, is_secret, description)
VALUES ('grocy_categories_json', ?, 0, 'Grocy 分类映射')
ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = NOW()`,
[JSON.stringify(arr)]);
invalidateCategoryMap();
ok(res, { removed: id });
});
// GET /api/chemicals/:id — 详情(本地 + Grocy 实数据)
router.get('/chemicals/:id', async (req, res) => {
const row = await db().get(`
SELECT c.*,
COALESCE(s.usage_count, 0) AS usage_count,
COALESCE(s.total_amount, 0) AS total_amount
FROM chemicals c
LEFT JOIN (
SELECT chemical_id, COUNT(*) AS usage_count, SUM(amount) AS total_amount
FROM chemical_usage GROUP BY chemical_id
) s ON s.chemical_id = c.grocy_product_id
WHERE c.grocy_product_id = ?`, [req.params.id]);
if (!row) return fail(res, 404, 'NOT_FOUND', '汽车用品不存在');
// 本系统近 50 条用量
const usageRows = await db().all(`
SELECT cu.id, cu.usage_date, cu.amount, cu.sync_status, w.id AS wash_id, w.wash_type, w.location, w.cost
FROM chemical_usage cu
LEFT JOIN wash_records w ON w.id = cu.wash_record_id
WHERE cu.chemical_id = ?
ORDER BY cu.usage_date DESC, cu.id DESC
LIMIT 50`, [req.params.id]);
let grocyLog = [], grocyError = null, grocyDetails = null;
const cfg = await loadConfig();
if (row.source === 'grocy' && cfg.grocy.url) {
try {
const [entries, details] = await Promise.all([
grocyGet(cfg, `api/stock/products/${req.params.id}/entries`, { timeout: 15000 }),
grocyGet(cfg, `api/stock/products/${req.params.id}`, { timeout: 15000 }).catch(() => null),
]);
grocyLog = Array.isArray(entries) ? entries : [];
grocyDetails = details;
} catch (e) { grocyError = e.message; }
}
ok(res, { ...enrich(row), usage_rows: usageRows, grocy_log: grocyLog, grocy_details: grocyDetails, grocy_error: grocyError });
});
// 写入后异步拉新数据
function pullInBackground(cfg) {
setImmediate(() => { pullProducts(cfg).catch(e => console.error('[async pull] failed:', e.message)); });
}
// PUT /api/chemicals/:id — 更新本系统覆盖字段
router.put('/chemicals/:id', async (req, res) => {
const row = await db().get('SELECT * FROM chemicals WHERE grocy_product_id = ?', [req.params.id]);
if (!row) return fail(res, 404, 'NOT_FOUND', '汽车用品不存在');
const b = req.body || {};
const allowed = {};
if (b.qu_factor !== undefined) {
const f = Number(b.qu_factor);
if (!Number.isFinite(f) || f < 0) return fail(res, 422, 'VALIDATION', 'qu_factor 必须是非负数');
allowed.qu_factor = f;
}
if (b.consume_unit_id !== undefined) {
const n = Number(b.consume_unit_id);
allowed.consume_unit_id = Number.isFinite(n) ? n : null;
}
if (b.consume_unit_name !== undefined) allowed.consume_unit_name = b.consume_unit_name || null;
if (b.min_stock_amount !== undefined) {
const n = Number(b.min_stock_amount);
allowed.min_stock_amount = Number.isFinite(n) ? n : null;
}
if (b.notes !== undefined) allowed.notes = b.notes || null;
if (Object.keys(allowed).length === 0) return fail(res, 422, 'VALIDATION', '无有效字段');
const sets = Object.keys(allowed).map(k => `${k} = ?`).join(', ');
const values = [...Object.values(allowed), req.params.id];
await db().run(`UPDATE chemicals SET ${sets} WHERE grocy_product_id = ?`, values);
const updated = await db().get('SELECT * FROM chemicals WHERE grocy_product_id = ?', [req.params.id]);
ok(res, enrich(updated));
});
// POST /api/chemicals — 在 Grocy 创建新 product
router.post('/chemicals', async (req, res) => {
try {
const cfg = await loadConfig();
if (!cfg.grocy.url) return fail(res, 400, 'NO_GROCY', '未配置 GROCY_URL');
const b = req.body || {};
if (!b.name || b.name.length > 120) return fail(res, 422, 'VALIDATION', 'name 必填且 ≤ 120 字');
const r = await createGrocyProduct(cfg, {
name: b.name, description: b.description, product_group_id: b.product_group_id,
qu_id_stock: b.qu_id_stock, qu_id_purchase: b.qu_id_purchase,
qu_factor_purchase_to_stock: b.qu_factor_purchase_to_stock,
location_id: b.location_id, shopping_location_id: b.shopping_location_id,
min_stock_amount: b.min_stock_amount, default_best_before_days: b.default_best_before_days,
});
const newId = r?.id || r?.created_object_id;
pullInBackground(cfg);
ok(res, { grocy_product_id: newId, created: true, grocy: r });
} catch (e) { fail(res, 500, 'CREATE_FAIL', e.message); }
});
// POST /api/chemicals/:id/add — Grocy 入库
router.post('/chemicals/:id/add', async (req, res) => {
try {
const cfg = await loadConfig();
const r = await addGrocyStock(cfg, req.params.id, req.body || {});
pullInBackground(cfg);
ok(res, { ok: true, grocy: r });
} catch (e) { fail(res, 500, 'STOCK_ADD_FAIL', e.message); }
});
// POST /api/chemicals/:id/consume — Grocy 扣减
router.post('/chemicals/:id/consume', async (req, res) => {
try {
const cfg = await loadConfig();
const r = await consumeGrocyStock(cfg, req.params.id, req.body || {});
pullInBackground(cfg);
ok(res, { ok: true, grocy: r });
} catch (e) { fail(res, 500, 'STOCK_CONSUME_FAIL', e.message); }
});
// POST /api/chemicals/:id/inventory — Grocy 盘点
router.post('/chemicals/:id/inventory', async (req, res) => {
try {
const cfg = await loadConfig();
const r = await inventoryGrocyStock(cfg, req.params.id, req.body || {});
pullInBackground(cfg);
ok(res, { ok: true, grocy: r });
} catch (e) { fail(res, 500, 'STOCK_INV_FAIL', e.message); }
});
// POST /api/chemicals/sync — 立即从 Grocy 拉
router.post('/chemicals/sync', syncRateLimit, async (req, res) => {
try {
const cfg = await loadConfig();
if (!cfg.grocy.url) return fail(res, 400, 'NO_GROCY', '未配置 GROCY_URL');
const r = await pullProducts(cfg, { dryRun: false });
// 简单标记同步时间:所有 grocy 来源的产品都更新
await db().run(`UPDATE chemicals SET grocy_last_pulled_at = NOW() WHERE source = 'grocy'`);
ok(res, r);
} catch (e) { fail(res, 500, 'SYNC_FAIL', e.message); }
});
// POST /api/chemicals/refresh-ids — 轻量同步
router.post('/chemicals/refresh-ids', syncRateLimit, async (req, res) => {
try {
const cfg = await loadConfig();
if (!cfg.grocy.url) return fail(res, 400, 'NO_GROCY', '未配置 GROCY_URL');
const all = await grocyGet(cfg, 'api/objects/products', { timeout: 30000 });
const items = Array.isArray(all) ? all : [];
const pulledIds = new Set(items.filter(p => p && p.id != null).map(p => String(p.id)));
let deactivated;
if (pulledIds.size === 0) {
const r = await db().run(`UPDATE chemicals SET is_active = 0, updated_at = NOW() WHERE source = 'grocy' AND is_active = 1`);
deactivated = r.changes;
} else {
const placeholders = [...pulledIds].map(() => '?').join(',');
const r = await db().run(`UPDATE chemicals SET is_active = 0, updated_at = NOW()
WHERE source = 'grocy' AND is_active = 1 AND grocy_product_id NOT IN (${placeholders})`, [...pulledIds]);
deactivated = r.changes;
}
await db().run(`UPDATE chemicals SET grocy_last_pulled_at = NOW() WHERE source = 'grocy'`);
ok(res, { pulled: pulledIds.size, deactivated });
} catch (e) { fail(res, 500, 'REFRESH_IDS_FAIL', e.message); }
});
export default router;
+270
View File
@@ -0,0 +1,270 @@
// server/src/routes/extra.js — 高 ROI 三件套:
// 1) 提醒中心(加油/保养/洗车长期未做)
// 2) 成本分类占比
// 3) 顶栏全局搜索
import { Router } from 'express';
import { db } from '../db.js';
const router = Router();
function ok(res, data) { res.json({ ok: true, data }); }
function fail(res, status, code, message) {
res.status(status).json({ ok: false, error: { code, message } });
}
// ============== 1) 提醒中心 ==============
// 拿阈值(用户可配置,默认 30/180/14 天)
async function getPrefs() {
const rows = await db().all('SELECT key_name, days, enabled FROM notification_prefs');
const out = {};
for (const r of rows) out[r.key_name] = { days: r.days, enabled: !!r.enabled };
return {
refuel: out.refuel_remind_days || { days: 30, enabled: true },
maintenance: out.maintenance_remind_days || { days: 180, enabled: true },
wash: out.wash_remind_days || { days: 14, enabled: true },
};
}
router.get('/reminders', async (req, res) => {
try {
const prefs = await getPrefs();
const today = new Date().toISOString().slice(0, 10);
const items = [];
if (prefs.refuel.enabled) {
// 每辆车:上次加油距今多少天
const rows = await db().all(`
SELECT v.id AS vehicle_id, v.name, v.plate, v.is_active,
MAX(r.refuel_date) AS last_date
FROM vehicles v
LEFT JOIN refuel_records r ON r.vehicle_id = v.id AND r.is_deleted = 0
WHERE v.is_active = 1
GROUP BY v.id
ORDER BY v.sort_order, v.id
`);
for (const r of rows) {
if (!r.last_date) {
items.push({ type: 'refuel', severity: 'info', vehicle_id: r.vehicle_id, vehicle: r.name, plate: r.plate, days: null, message: '还没有加油记录' });
continue;
}
const days = Math.floor((Date.now() - new Date(r.last_date).getTime()) / 86400000);
if (days >= prefs.refuel.days) {
items.push({ type: 'refuel', severity: days > prefs.refuel.days * 1.5 ? 'warn' : 'info', vehicle_id: r.vehicle_id, vehicle: r.name, plate: r.plate, days, last_date: r.last_date, message: `已经 ${days} 天没加油(阈值 ${prefs.refuel.days} 天)` });
}
}
}
if (prefs.maintenance.enabled) {
const rows = await db().all(`
SELECT v.id AS vehicle_id, v.name, v.plate,
MAX(m.maint_date) AS last_date
FROM vehicles v
LEFT JOIN maintenance_records m ON m.vehicle_id = v.id AND m.is_deleted = 0
WHERE v.is_active = 1
GROUP BY v.id
ORDER BY v.sort_order, v.id
`);
for (const r of rows) {
if (!r.last_date) {
items.push({ type: 'maintenance', severity: 'info', vehicle_id: r.vehicle_id, vehicle: r.name, plate: r.plate, days: null, message: '还没有保养记录' });
continue;
}
const days = Math.floor((Date.now() - new Date(r.last_date).getTime()) / 86400000);
if (days >= prefs.maintenance.days) {
items.push({ type: 'maintenance', severity: days > prefs.maintenance.days * 1.5 ? 'warn' : 'info', vehicle_id: r.vehicle_id, vehicle: r.name, plate: r.plate, days, last_date: r.last_date, message: `已经 ${days} 天没保养(阈值 ${prefs.maintenance.days} 天)` });
}
}
}
if (prefs.wash.enabled) {
const rows = await db().all(`
SELECT v.id AS vehicle_id, v.name, v.plate,
MAX(w.wash_date) AS last_date
FROM vehicles v
LEFT JOIN wash_records w ON w.vehicle_id = v.id AND w.is_deleted = 0
WHERE v.is_active = 1
GROUP BY v.id
ORDER BY v.sort_order, v.id
`);
for (const r of rows) {
if (!r.last_date) {
items.push({ type: 'wash', severity: 'info', vehicle_id: r.vehicle_id, vehicle: r.name, plate: r.plate, days: null, message: '还没有洗车记录' });
continue;
}
const days = Math.floor((Date.now() - new Date(r.last_date).getTime()) / 86400000);
if (days >= prefs.wash.days) {
items.push({ type: 'wash', severity: 'warn', vehicle_id: r.vehicle_id, vehicle: r.name, plate: r.plate, days, last_date: r.last_date, message: `已经 ${days} 天没洗车(阈值 ${prefs.wash.days} 天)` });
}
}
}
// 按严重度排序:warn > infodays 大的排前
items.sort((a, b) => {
const s = (b.severity === 'warn') - (a.severity === 'warn');
if (s) return s;
return (b.days || 0) - (a.days || 0);
});
ok(res, { today, prefs, items, total: items.length });
} catch (e) {
fail(res, 500, 'REMINDER_ERR', e.message);
}
});
// GET/PUT 阈值
router.get('/reminders/prefs', async (req, res) => ok(res, await getPrefs()));
router.put('/reminders/prefs', async (req, res) => {
const b = req.body || {};
const allowed = { refuel_remind_days: 'refuel', maintenance_remind_days: 'maintenance', wash_remind_days: 'wash' };
for (const [k, label] of Object.entries(allowed)) {
if (b[label] && typeof b[label] === 'object') {
if (typeof b[label].days === 'number' && b[label].days > 0) {
await db().run('UPDATE notification_prefs SET days = ? WHERE key_name = ?', [b[label].days, k]);
}
if (typeof b[label].enabled === 'boolean') {
await db().run('UPDATE notification_prefs SET enabled = ? WHERE key_name = ?', [b[label].enabled ? 1 : 0, k]);
}
}
}
ok(res, await getPrefs());
});
// ============== 2) 成本分类占比 ==============
router.get('/stats/cost-breakdown', async (req, res) => {
try {
const from = req.query.from || null;
const to = req.query.to || null;
// 各领域分别求和(注意 insurance 用 premium 列)
const sql = (table, dateCol, costCol, where = '1=1') => {
let q = `SELECT ROUND(COALESCE(SUM(${costCol}), 0), 2) AS total FROM ${table} WHERE ${where}`;
const args = [];
if (from) { q += ` AND ${dateCol} >= ?`; args.push(from); }
if (to) { q += ` AND ${dateCol} <= ?`; args.push(to); }
return db().get(q, args);
};
const [w, r, c, m, i] = await Promise.all([
sql('wash_records', 'wash_date', 'cost', 'is_deleted = 0'),
sql('refuel_records', 'refuel_date', 'total_cost', 'is_deleted = 0'),
sql('charging_records', 'charge_date', 'total_cost', 'is_deleted = 0'),
sql('maintenance_records', 'maint_date', 'total_cost', 'is_deleted = 0'),
sql('insurance_records', 'start_date', 'premium', 'is_deleted = 0'),
]);
const total = (w?.total || 0) + (r?.total || 0) + (c?.total || 0) + (m?.total || 0) + (i?.total || 0);
const pct = (n) => total > 0 ? Number(((n / total) * 100).toFixed(1)) : 0;
ok(res, {
from, to,
total: Number(total.toFixed(2)),
categories: [
{ key: 'wash', label: '洗车', total: Number(w?.total || 0), pct: pct(w?.total || 0), color: '#4DBA9A' },
{ key: 'refuel', label: '加油', total: Number(r?.total || 0), pct: pct(r?.total || 0), color: '#E89653' },
{ key: 'charge', label: '充电', total: Number(c?.total || 0), pct: pct(c?.total || 0), color: '#1E5B8A' },
{ key: 'maintenance', label: '保养', total: Number(m?.total || 0), pct: pct(m?.total || 0), color: '#9B59B6' },
{ key: 'insurance', label: '保险', total: Number(i?.total || 0), pct: pct(i?.total || 0), color: '#D17A3A' },
],
});
} catch (e) {
fail(res, 500, 'BREAKDOWN_ERR', e.message);
}
});
// ============== 3) 顶栏全局搜索 ==============
// 在所有领域搜:车牌 / 商家 / 保单号 / 加油地点 / 保养店 / 备注
router.get('/search', async (req, res) => {
try {
const q = (req.query.q || '').trim();
if (!q) return ok(res, { q, total: 0, groups: {} });
const like = `%${q}%`;
const [vehicles, washes, refuels, charges, maints, insurances, chemicals] = await Promise.all([
db().all(`SELECT id, name, plate, type FROM vehicles WHERE is_active = 1 AND (name LIKE ? OR plate LIKE ? OR notes LIKE ?) LIMIT 10`, [like, like, like]),
db().all(`SELECT w.id, w.wash_date, w.cost, w.location, w.notes, v.name AS vehicle_name, v.plate FROM wash_records w LEFT JOIN vehicles v ON v.id = w.vehicle_id WHERE w.is_deleted = 0 AND (w.location LIKE ? OR w.notes LIKE ?) ORDER BY w.wash_date DESC LIMIT 10`, [like, like]),
db().all(`SELECT r.id, r.refuel_date, r.total_cost, r.station, r.notes, v.name AS vehicle_name, v.plate FROM refuel_records r LEFT JOIN vehicles v ON v.id = r.vehicle_id WHERE r.is_deleted = 0 AND (r.station LIKE ? OR r.notes LIKE ?) ORDER BY r.refuel_date DESC LIMIT 10`, [like, like]),
db().all(`SELECT c.id, c.charge_date, c.total_cost, c.station, c.notes, v.name AS vehicle_name, v.plate FROM charging_records c LEFT JOIN vehicles v ON v.id = c.vehicle_id WHERE c.is_deleted = 0 AND (c.station LIKE ? OR c.notes LIKE ?) ORDER BY c.charge_date DESC LIMIT 10`, [like, like]),
db().all(`SELECT m.id, m.maint_date, m.total_cost, m.shop, m.notes, v.name AS vehicle_name, v.plate FROM maintenance_records m LEFT JOIN vehicles v ON v.id = m.vehicle_id WHERE m.is_deleted = 0 AND (m.shop LIKE ? OR m.notes LIKE ?) ORDER BY m.maint_date DESC LIMIT 10`, [like, like]),
db().all(`SELECT i.id, i.start_date, i.end_date, i.premium, i.insurance_type, i.company, i.policy_no, v.name AS vehicle_name, v.plate FROM insurance_records i LEFT JOIN vehicles v ON v.id = i.vehicle_id WHERE i.is_deleted = 0 AND (i.company LIKE ? OR i.policy_no LIKE ? OR i.insurance_type LIKE ? OR i.notes LIKE ?) ORDER BY i.start_date DESC LIMIT 10`, [like, like, like, like]),
db().all(`SELECT grocy_product_id AS id, name, category, unit, current_amount FROM chemicals WHERE is_active = 1 AND (name LIKE ? OR category LIKE ? OR notes LIKE ?) LIMIT 10`, [like, like, like]),
]);
// 格式化匹配字段
const fmt = (rows, fieldMap) => rows.map(r => {
const matched = [];
for (const [field, label] of Object.entries(fieldMap)) {
if (r[field] && String(r[field]).includes(q)) matched.push({ field, label, snippet: String(r[field]).slice(0, 60) });
}
return { ...r, _matched: matched };
});
const groups = {
vehicles: { label: '车辆', rows: vehicles.map(v => ({ ...v, _matched: [{ field: v.plate && v.plate.includes(q) ? 'plate' : 'name', label: v.plate && v.plate.includes(q) ? '车牌' : '名称', snippet: v.plate || v.name }] })) },
washes: { label: '洗车', rows: fmt(washes, { location: '地点', notes: '备注', vehicle_name: '车辆' }) },
refuels: { label: '加油', rows: fmt(refuels, { station: '加油站', notes: '备注', vehicle_name: '车辆' }) },
charges: { label: '充电', rows: fmt(charges, { station: '充电站', notes: '备注', vehicle_name: '车辆' }) },
maints: { label: '保养', rows: fmt(maints, { shop: '店家', notes: '备注', vehicle_name: '车辆' }) },
insurances: { label: '保险', rows: fmt(insurances, { company: '公司', policy_no: '保单号', insurance_type: '类型', notes: '备注', vehicle_name: '车辆' }) },
chemicals: { label: '化学品', rows: fmt(chemicals, { name: '名称', category: '分类', notes: '备注' }) },
};
const total = Object.values(groups).reduce((s, g) => s + g.rows.length, 0);
ok(res, { q, total, groups });
} catch (e) {
fail(res, 500, 'SEARCH_ERR', e.message);
}
});
// ============== 同比/环比 ==============
// 本月 vs 上月 / 本季 vs 上季 / 今年 vs 去年,5 个领域各给一次
router.get('/stats/compare', async (req, res) => {
try {
const tableMap = {
wash: { table: 'wash_records', dateCol: 'wash_date', costCol: 'cost', deletedCol: 'is_deleted' },
refuel: { table: 'refuel_records', dateCol: 'refuel_date', costCol: 'total_cost', deletedCol: 'is_deleted' },
charge: { table: 'charging_records', dateCol: 'charge_date', costCol: 'total_cost', deletedCol: 'is_deleted' },
maintenance: { table: 'maintenance_records', dateCol: 'maint_date', costCol: 'total_cost', deletedCol: 'is_deleted' },
insurance: { table: 'insurance_records', dateCol: 'start_date', costCol: 'premium', deletedCol: 'is_deleted' },
};
const countMap = {
wash: { table: 'wash_records', dateCol: 'wash_date' },
refuel: { table: 'refuel_records', dateCol: 'refuel_date' },
charge: { table: 'charging_records', dateCol: 'charge_date' },
maintenance: { table: 'maintenance_records', dateCol: 'maint_date' },
};
// 三个区间(用 UTC 方法,跟 db.js timezone:'Z' 一致)
const now = new Date();
const y = now.getUTCFullYear();
const m = now.getUTCMonth();
const fmt = (d) => `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
const startOfMonth = new Date(Date.UTC(y, m, 1));
const startOfPrevMonth = new Date(Date.UTC(y, m - 1, 1));
const startOfYear = new Date(Date.UTC(y, 0, 1));
const startOfPrevYear = new Date(Date.UTC(y - 1, 0, 1));
const endOfPrevMonth = new Date(Date.UTC(y, m, 0));
const endOfPrevYear = new Date(Date.UTC(y - 1, 11, 31));
const today = fmt(now);
const ranges = {
month: { from: fmt(startOfMonth), to: today },
prevMonth:{ from: fmt(startOfPrevMonth),to: fmt(endOfPrevMonth) },
ytd: { from: fmt(startOfYear), to: today },
prevYtd: { from: fmt(startOfPrevYear), to: fmt(endOfPrevYear) },
};
const sumIn = async (cfg, from, to) => {
const r = await db().get(
`SELECT COALESCE(SUM(${cfg.costCol}), 0) AS total, COUNT(*) AS cnt
FROM ${cfg.table}
WHERE ${cfg.deletedCol} = 0 AND ${cfg.dateCol} >= ? AND ${cfg.dateCol} <= ?`,
[from, to]
);
return { total: Number(r.total || 0), count: Number(r.cnt || 0) };
};
const result = {};
for (const [k, cfg] of Object.entries(tableMap)) {
const cur = await sumIn(cfg, ranges.month.from, ranges.month.to);
const prev = await sumIn(cfg, ranges.prevMonth.from, ranges.prevMonth.to);
const ytd = await sumIn(cfg, ranges.ytd.from, ranges.ytd.to);
const pytd = await sumIn(cfg, ranges.prevYtd.from, ranges.prevYtd.to);
const deltaPct = (a, b) => b > 0 ? Number((((a - b) / b) * 100).toFixed(1)) : null;
result[k] = {
this_month: cur,
last_month: prev,
mom_pct: deltaPct(cur.total, prev.total),
this_ytd: ytd,
last_ytd: pytd,
yoy_pct: deltaPct(ytd.total, pytd.total),
};
}
ok(res, { today, ranges, by_category: result });
} catch (e) {
fail(res, 500, 'COMPARE_ERR', e.message);
}
});
export default router;
+258
View File
@@ -0,0 +1,258 @@
// server/src/routes/insurance.js — 保险记录 CRUD + 附件上传
/**
* @openapi
* /api/insurances:
* get:
* tags: [insurances]
* summary: 列出保单
* post:
* tags: [insurances]
* summary: 新建保单
* /api/insurances/{id}:
* get: { tags: [insurances], summary: 保单详情 }
* put: { tags: [insurances], summary: 更新保单 }
* delete: { tags: [insurances], summary: 软删保单 }
* /api/insurances/{id}/upload:
* post:
* tags: [insurances]
* summary: 上传保单附件
*/
import { Router } from 'express';
import { db } from '../db.js';
import multer from 'multer';
import path from 'node:path';
import fs from 'node:fs';
import url from 'node:url';
import { logOperation } from '../services/operationLog.js';
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const UPLOAD_DIR = path.join(__dirname, '../../../uploads/insurance');
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const router = Router();
function ok(res, data) {
res.json(data);
}
function fail(res, status, code, message, extra) {
res.status(status).json({ ok: false, error: { code, message, ...(extra || {}) } });
}
// multer 配置
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
filename: (req, file, cb) => {
const ts = Date.now();
const ext = path.extname(file.originalname).toLowerCase().slice(0, 8) || '.bin';
const rand = Math.random().toString(36).slice(2, 8);
cb(null, `v${req.params.id || 'new'}-${ts}-${rand}${ext}`);
},
});
const ALLOWED_MIMES = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/heic', 'application/pdf']);
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (ALLOWED_MIMES.has(file.mimetype)) cb(null, true);
else cb(new Error(`不支持的文件类型:${file.mimetype}(仅图片/PDF`));
},
});
const TYPES = ['交强险', '商业险', '车损险', '三责险', '座位险', '不计免赔', '玻璃险', '划痕险', '自燃险', '涉水险'];
async function checkVehicle(vehicleId) {
if (!vehicleId) return null;
return await db().get('SELECT id, name, plate FROM vehicles WHERE id = ? AND is_active = 1', [vehicleId]);
}
function daysUntil(endDate) {
return Math.ceil((new Date(endDate) - new Date()) / 86400000);
}
function statusOf(endDate) {
const d = daysUntil(endDate);
if (d < 0) return 'expired';
if (d <= 30) return 'expiring';
return 'active';
}
// GET /api/insurances — 列表
router.get('/insurances', async (req, res) => {
const { vehicle_id, type, active } = req.query;
const where = ['x.is_deleted = 0'],
params = [];
if (vehicle_id) {
where.push('x.vehicle_id = ?');
params.push(vehicle_id);
}
if (type) {
where.push('x.insurance_type = ?');
params.push(type);
}
const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : '';
const today = new Date().toISOString().slice(0, 10);
const rows = await db().all(
`SELECT x.*, v.name AS vehicle_name, v.plate AS vehicle_plate
FROM insurance_records x LEFT JOIN vehicles v ON v.id = x.vehicle_id
${whereSql} ORDER BY x.end_date DESC, x.id DESC`,
params
);
const enriched = rows.map((r) => {
const days = Math.ceil((new Date(r.end_date) - new Date(today)) / 86400000);
let status = 'active';
if (days < 0) status = 'expired';
else if (days <= 30) status = 'expiring';
return { ...r, days_to_expire: days, status };
});
const filtered = !active ? enriched : enriched.filter((r) => r.status === active);
const total_cost = enriched.reduce((s, r) => s + (r.premium || 0), 0);
ok(res, {
rows: filtered,
total: filtered.length,
stats: {
total: enriched.length,
active_count: enriched.filter((r) => r.status === 'active').length,
expiring_count: enriched.filter((r) => r.status === 'expiring').length,
expired_count: enriched.filter((r) => r.status === 'expired').length,
total_premium: total_cost,
},
types: TYPES,
});
});
// GET /api/insurances/:id — 详情
router.get('/insurances/:id', async (req, res) => {
const r = await db().get(
`SELECT x.*, v.name AS vehicle_name, v.plate AS vehicle_plate
FROM insurance_records x LEFT JOIN vehicles v ON v.id = x.vehicle_id WHERE x.id = ?`,
[req.params.id]
);
if (!r) return fail(res, 404, 'NOT_FOUND', '记录不存在');
const days = Math.ceil((new Date(r.end_date) - new Date()) / 86400000);
let status = 'active';
if (days < 0) status = 'expired';
else if (days <= 30) status = 'expiring';
ok(res, { ...r, days_to_expire: days, status });
});
// POST /api/insurances — 新建
router.post('/insurances', async (req, res) => {
const b = req.body || {};
const errors = {};
if (!b.vehicle_id) errors.vehicle_id = '必填';
if (!b.insurance_type) errors.insurance_type = '必填';
if (!b.start_date || !/^\d{4}-\d{2}-\d{2}$/.test(b.start_date)) errors.start_date = '必填(YYYY-MM-DD';
if (!b.end_date || !/^\d{4}-\d{2}-\d{2}$/.test(b.end_date)) errors.end_date = '必填(YYYY-MM-DD';
else if (b.end_date <= b.start_date) errors.end_date = '必须晚于生效日';
if (b.premium != null && (isNaN(b.premium) || Number(b.premium) < 0)) errors.premium = '必须 ≥ 0';
if (Object.keys(errors).length) return fail(res, 422, 'VALIDATION', '校验失败', { errors });
if (!(await checkVehicle(b.vehicle_id)))
return fail(res, 422, 'VALIDATION', '车辆不存在或已停用', { field: 'vehicle_id' });
const r = await db().run(
`INSERT INTO insurance_records
(vehicle_id, insurance_type, company, policy_no, start_date, end_date, premium, coverage_amount, notes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`,
[
b.vehicle_id,
b.insurance_type,
b.company || null,
b.policy_no || null,
b.start_date,
b.end_date,
b.premium != null ? Number(b.premium) : null,
b.coverage_amount != null ? Number(b.coverage_amount) : null,
b.notes || null,
]
);
const newR = await db().get('SELECT * FROM insurance_records WHERE id = ?', [Number(r.lastInsertRowid)]);
ok(res, { ...newR, days_to_expire: daysUntil(newR.end_date), status: statusOf(newR.end_date) });
});
// PUT /api/insurances/:id — 更新
router.put('/insurances/:id', async (req, res) => {
const r = await db().get('SELECT * FROM insurance_records WHERE id = ?', [req.params.id]);
if (!r) return fail(res, 404, 'NOT_FOUND', '记录不存在');
const b = req.body || {};
if (b.vehicle_id !== undefined && !(await checkVehicle(b.vehicle_id))) {
return fail(res, 422, 'VALIDATION', '车辆不存在或已停用', { field: 'vehicle_id' });
}
const allowed = [
'vehicle_id',
'insurance_type',
'company',
'policy_no',
'start_date',
'end_date',
'premium',
'coverage_amount',
'notes',
];
const sets = [],
values = [];
for (const k of allowed) {
if (b[k] !== undefined) {
sets.push(`${k} = ?`);
values.push(b[k]);
}
}
if (sets.length === 0) return fail(res, 422, 'VALIDATION', '无有效字段');
sets.push(`updated_at = NOW()`);
values.push(req.params.id);
await db().run(`UPDATE insurance_records SET ${sets.join(', ')} WHERE id = ?`, values);
const upd = await db().get('SELECT * FROM insurance_records WHERE id = ?', [req.params.id]);
ok(res, { ...upd, days_to_expire: daysUntil(upd.end_date), status: statusOf(upd.end_date) });
});
// DELETE /api/insurances/:id — 软删
router.delete('/insurances/:id', async (req, res) => {
const r = await db().get('SELECT * FROM insurance_records WHERE id = ? AND is_deleted = 0', [req.params.id]);
if (!r) return fail(res, 404, 'NOT_FOUND', '记录不存在或已删除');
logOperation({
req,
action: 'delete',
targetType: 'insurance',
targetIds: [r.id],
summary: `删除保险「${r.insurance_type || '记录'}」¥${Number(r.premium || 0).toFixed(2)} / ${r.vehicle_plate || ''}`,
detail: { record: r },
});
await db().run('UPDATE insurance_records SET is_deleted = 1 WHERE id = ?', [req.params.id]);
ok(res, { id: Number(req.params.id), deleted: true });
});
// POST /api/insurances/:id/upload — 上传保单附件
router.post('/insurances/:id/upload', upload.single('file'), async (req, res) => {
const r = await db().get('SELECT id FROM insurance_records WHERE id = ?', [req.params.id]);
if (!r) {
if (req.file) fs.unlink(req.file.path, () => {});
return fail(res, 404, 'NOT_FOUND', '记录不存在');
}
if (!req.file) return fail(res, 422, 'NO_FILE', '请选择文件');
const old = await db().get('SELECT attachment_path FROM insurance_records WHERE id = ?', [req.params.id]);
if (old?.attachment_path) fs.unlink(path.join(__dirname, '../../..', old.attachment_path), () => {});
const relPath = path.relative(path.join(__dirname, '../../..'), req.file.path).replace(/\\/g, '/');
await db().run(
`UPDATE insurance_records SET attachment_path = ?, attachment_name = ?,
attachment_mime = ?, attachment_size = ?, updated_at = NOW() WHERE id = ?`,
[relPath, req.file.originalname, req.file.mimetype, req.file.size, req.params.id]
);
const upd = await db().get(
'SELECT id, attachment_path, attachment_name, attachment_mime, attachment_size FROM insurance_records WHERE id = ?',
[req.params.id]
);
ok(res, upd);
});
// DELETE /api/insurances/:id/attachment — 删附件
router.delete('/insurances/:id/attachment', async (req, res) => {
const r = await db().get('SELECT attachment_path FROM insurance_records WHERE id = ?', [req.params.id]);
if (!r) return fail(res, 404, 'NOT_FOUND', '记录不存在');
if (!r.attachment_path) return fail(res, 404, 'NO_ATTACHMENT', '无附件');
fs.unlink(path.join(__dirname, '../../..', r.attachment_path), () => {});
await db().run(
`UPDATE insurance_records SET attachment_path = NULL, attachment_name = NULL,
attachment_mime = NULL, attachment_size = NULL, updated_at = NOW() WHERE id = ?`,
[req.params.id]
);
ok(res, { id: Number(req.params.id), deleted_attachment: true });
});
export default router;
+311
View File
@@ -0,0 +1,311 @@
// server/src/routes/logs.js — 保养 / 加油 / 充电 三个领域用同一套 CRUD 模板
import { Router } from 'express';
import { db } from '../db.js';
import { logOperation } from '../services/operationLog.js';
function ok(res, data) {
res.json(data);
}
function fail(res, status, code, message, extra) {
res.status(status).json({ ok: false, error: { code, message, ...(extra || {}) } });
}
function makeRouter(cfg) {
const router = Router();
const base = '/' + cfg.domain;
const DELETED = `${cfg.table}.is_deleted = 0`;
async function checkVehicle(vehicleId) {
if (!vehicleId) return null;
return await db().get(
'SELECT id, name, plate FROM vehicles WHERE id = ? AND is_active = 1 AND is_deleted = 0',
[vehicleId]
);
}
// GET 列表
router.get(base, async (req, res) => {
const { vehicle_id, from, to } = req.query;
const page = Math.max(1, parseInt(req.query.page || '1'));
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit || '20')));
const offset = (page - 1) * limit;
const where = [DELETED],
params = [];
if (vehicle_id) {
where.push(`${cfg.table}.vehicle_id = ?`);
params.push(vehicle_id);
}
if (from) {
where.push(`${cfg.table}.${cfg.dateCol} >= ?`);
params.push(from);
}
if (to) {
where.push(`${cfg.table}.${cfg.dateCol} <= ?`);
params.push(to);
}
const whereSql = 'WHERE ' + where.join(' AND ');
const rows = await db().all(
`SELECT ${cfg.table}.*, v.name AS vehicle_name, v.plate AS vehicle_plate, v.type AS vehicle_type
FROM ${cfg.table} LEFT JOIN vehicles v ON v.id = ${cfg.table}.vehicle_id
${whereSql} ORDER BY ${cfg.table}.${cfg.dateCol} DESC, ${cfg.table}.id DESC LIMIT ? OFFSET ?`,
[...params, limit, offset]
);
const total = (await db().get(`SELECT COUNT(*) AS c FROM ${cfg.table} ${whereSql}`, params))?.c || 0;
const stats = await db().get(
`SELECT COUNT(*) AS count, COALESCE(SUM(total_cost), 0) AS total_cost FROM ${cfg.table} ${whereSql}`,
params
);
ok(res, {
rows: rows.map((r) => cfg.enrich(r)),
total,
page,
limit,
total_pages: Math.ceil(total / limit),
stats: { count: stats?.count || 0, total_cost: stats?.total_cost || 0 },
});
});
// GET 详情
router.get(`${base}/:id`, async (req, res) => {
const r = await db().get(
`SELECT ${cfg.table}.*, v.name AS vehicle_name, v.plate AS vehicle_plate, v.type AS vehicle_type
FROM ${cfg.table} LEFT JOIN vehicles v ON v.id = ${cfg.table}.vehicle_id
WHERE ${cfg.table}.id = ? AND ${DELETED}`,
[req.params.id]
);
if (!r) return fail(res, 404, 'NOT_FOUND', '记录不存在');
ok(res, cfg.enrich(r));
});
// POST 新建
router.post(base, async (req, res) => {
const b = req.body || {};
// maintenance: 兼容前端传 items(数组)→ DB 列 items_jsonJSON 字符串)
if (cfg.table === 'maintenance_records' && Array.isArray(b.items)) {
b.items_json = JSON.stringify(b.items);
delete b.items;
}
const errors = cfg.validate(b);
if (Object.keys(errors).length) return fail(res, 422, 'VALIDATION', '校验失败', { errors });
const v = await checkVehicle(b.vehicle_id);
if (!v) return fail(res, 422, 'VALIDATION', '车辆不存在或已停用', { field: 'vehicle_id' });
const fields = cfg.fields;
const values = fields.map((f) => (b[f.key] !== undefined ? b[f.key] : f.default));
const ins = await db().run(
`INSERT INTO ${cfg.table} (${fields.map((f) => f.col).join(', ')}, created_at, updated_at, is_deleted)
VALUES (${fields.map(() => '?').join(', ')}, NOW(), NOW(), 0)`,
values
);
const r = await db().get(`SELECT * FROM ${cfg.table} WHERE id = ?`, [Number(ins.lastInsertRowid)]);
ok(res, cfg.enrich(r));
});
// PUT 更新
router.put(`${base}/:id`, async (req, res) => {
const r = await db().get(`SELECT * FROM ${cfg.table} WHERE id = ? AND is_deleted = 0`, [req.params.id]);
if (!r) return fail(res, 404, 'NOT_FOUND', '记录不存在');
const b = req.body || {};
// maintenance: items 数组 → items_json 字符串
if (cfg.table === 'maintenance_records' && Array.isArray(b.items)) {
b.items_json = JSON.stringify(b.items);
delete b.items;
}
if (b.vehicle_id !== undefined) {
const v = await checkVehicle(b.vehicle_id);
if (!v) return fail(res, 422, 'VALIDATION', '车辆不存在或已停用', { field: 'vehicle_id' });
}
const sets = [],
values = [];
for (const f of cfg.fields) {
if (b[f.key] !== undefined) {
sets.push(`${f.col} = ?`);
values.push(b[f.key]);
}
}
if (sets.length === 0) return fail(res, 422, 'VALIDATION', '无有效字段');
sets.push(`updated_at = NOW()`);
values.push(req.params.id);
await db().run(`UPDATE ${cfg.table} SET ${sets.join(', ')} WHERE id = ?`, values);
const updated = await db().get(`SELECT * FROM ${cfg.table} WHERE id = ?`, [req.params.id]);
ok(res, cfg.enrich(updated));
});
// DELETE 软删
router.delete(`${base}/:id`, async (req, res) => {
const r = await db().get(`SELECT * FROM ${cfg.table} WHERE id = ? AND is_deleted = 0`, [req.params.id]);
if (!r) return fail(res, 404, 'NOT_FOUND', '记录不存在');
logOperation({
req,
action: 'delete',
targetType: cfg.logType || cfg.domain,
targetIds: [r.id],
summary: cfg.deleteSummary(r),
detail: { record: r },
});
await db().run(`UPDATE ${cfg.table} SET is_deleted = 1, updated_at = NOW() WHERE id = ?`, [req.params.id]);
ok(res, { id: Number(req.params.id), deleted: true });
});
return router;
}
/**
* @openapi
* /api/maintenances:
* get:
* tags: [maintenance]
* summary: 列出保养记录
* post:
* tags: [maintenance]
* summary: 新建保养记录
* /api/maintenances/{id}:
* get: { tags: [maintenance], summary: 保养详情 }
* put: { tags: [maintenance], summary: 更新保养 }
* delete: { tags: [maintenance], summary: 软删保养 }
* /api/refuels:
* get:
* tags: [refuels]
* summary: 列出加油记录
* post:
* tags: [refuels]
* summary: 新建加油记录
* /api/refuels/{id}:
* get: { tags: [refuels], summary: 加油详情 }
* put: { tags: [refuels], summary: 更新加油 }
* delete: { tags: [refuels], summary: 软删加油 }
* /api/chargings:
* get:
* tags: [charges]
* summary: 列出充电记录
* post:
* tags: [charges]
* summary: 新建充电记录
* /api/chargings/{id}:
* get: { tags: [charges], summary: 充电详情 }
* put: { tags: [charges], summary: 更新充电 }
* delete: { tags: [charges], summary: 软删充电 }
*/
// ===== 保养 =====
const maintRouter = makeRouter({
domain: 'maintenances',
table: 'maintenance_records',
logType: 'maintenance',
deleteSummary(r) {
return `删除保养 ${r.maint_date} ¥${Number(r.total_cost || 0).toFixed(2)} / ${r.vehicle_name || ''}`;
},
dateCol: 'maint_date',
fields: [
{ key: 'vehicle_id', col: 'vehicle_id' },
{ key: 'maint_date', col: 'maint_date' },
{ key: 'odometer_km', col: 'odometer_km' },
{ key: 'ev_km', col: 'ev_km' },
{ key: 'hev_km', col: 'hev_km' },
{ key: 'total_cost', col: 'total_cost' },
{ key: 'shop', col: 'shop' },
{ key: 'items_json', col: 'items_json' },
{ key: 'next_due_date', col: 'next_due_date' },
{ key: 'next_due_km', col: 'next_due_km' },
{ key: 'notes', col: 'notes' },
].map((f) => ({ ...f, default: null })),
validate(b) {
const e = {};
if (!b.vehicle_id) e.vehicle_id = '必填';
if (!b.maint_date || !/^\d{4}-\d{2}-\d{2}$/.test(b.maint_date)) e.maint_date = '必填(YYYY-MM-DD';
else if (b.maint_date > new Date().toISOString().slice(0, 10)) e.maint_date = '不能晚于今天';
if (b.total_cost == null || isNaN(b.total_cost) || Number(b.total_cost) < 0) e.total_cost = '必填且 ≥ 0';
return e;
},
enrich(r) {
let items = [];
if (Array.isArray(r.items_json))
items = r.items_json; // MySQL JSON 列自动解析
else if (typeof r.items_json === 'string') {
try {
items = JSON.parse(r.items_json);
} catch {
items = [];
}
}
return { ...r, items, items_json: undefined };
},
});
// ===== 加油 =====
const refuelRouter = makeRouter({
domain: 'refuels',
table: 'refuel_records',
dateCol: 'refuel_date',
logType: 'refuel',
deleteSummary(r) {
return `删除加油 ${r.refuel_date} ${r.liters}L ¥${Number(r.total_cost || 0).toFixed(2)} / ${r.vehicle_name || ''}`;
},
fields: [
{ key: 'vehicle_id', col: 'vehicle_id' },
{ key: 'refuel_date', col: 'refuel_date' },
{ key: 'odometer_km', col: 'odometer_km' },
{ key: 'liters', col: 'liters' },
{ key: 'price_per_liter', col: 'price_per_liter' },
{ key: 'total_cost', col: 'total_cost' },
{ key: 'fuel_type', col: 'fuel_type' },
{ key: 'is_full', col: 'is_full' },
{ key: 'station', col: 'station' },
{ key: 'notes', col: 'notes' },
].map((f) => ({ ...f, default: null })),
validate(b) {
const e = {};
if (!b.vehicle_id) e.vehicle_id = '必填';
if (!b.refuel_date || !/^\d{4}-\d{2}-\d{2}$/.test(b.refuel_date)) e.refuel_date = '必填(YYYY-MM-DD';
else if (b.refuel_date > new Date().toISOString().slice(0, 10)) e.refuel_date = '不能晚于今天';
if (!b.liters || isNaN(b.liters) || Number(b.liters) <= 0) e.liters = '必填且 > 0';
if (b.total_cost == null || isNaN(b.total_cost) || Number(b.total_cost) < 0) e.total_cost = '必填且 ≥ 0';
return e;
},
enrich(r) {
// 默认占位:油耗由前端 health 单独算(基于 is_full + 里程)
return { ...r, consumption_skip_reason: null, km_per_l: null, consumption_100km: null };
},
});
// ===== 充电 =====
const chargingRouter = makeRouter({
domain: 'chargings',
table: 'charging_records',
dateCol: 'charge_date',
logType: 'charging',
deleteSummary(r) {
return `删除充电 ${r.charge_date} ${r.kwh}kWh ¥${Number(r.total_cost || 0).toFixed(2)} / ${r.vehicle_name || ''}`;
},
fields: [
{ key: 'vehicle_id', col: 'vehicle_id' },
{ key: 'charge_date', col: 'charge_date' },
{ key: 'odometer_km', col: 'odometer_km' },
{ key: 'kwh', col: 'kwh' },
{ key: 'price_per_kwh', col: 'price_per_kwh' },
{ key: 'total_cost', col: 'total_cost' },
{ key: 'charge_type', col: 'charge_type' },
{ key: 'start_soc', col: 'start_soc' },
{ key: 'end_soc', col: 'end_soc' },
{ key: 'station', col: 'station' },
{ key: 'notes', col: 'notes' },
].map((f) => ({ ...f, default: null })),
validate(b) {
const e = {};
if (!b.vehicle_id) e.vehicle_id = '必填';
if (!b.charge_date || !/^\d{4}-\d{2}-\d{2}$/.test(b.charge_date)) e.charge_date = '必填(YYYY-MM-DD';
else if (b.charge_date > new Date().toISOString().slice(0, 10)) e.charge_date = '不能晚于今天';
if (!b.kwh || isNaN(b.kwh) || Number(b.kwh) <= 0) e.kwh = '必填且 > 0';
if (b.total_cost == null || isNaN(b.total_cost) || Number(b.total_cost) < 0) e.total_cost = '必填且 ≥ 0';
return e;
},
enrich(r) {
return { ...r, consumption_skip_reason: null, kwh_per_100km: null };
},
});
export { maintRouter, refuelRouter, chargingRouter };
export default maintRouter;
+67
View File
@@ -0,0 +1,67 @@
// server/src/routes/notifications.js — 站内通知中心
import { Router } from 'express';
import { db } from '../db.js';
const router = Router();
function ok(res, data) { res.json({ ok: true, data }); }
function fail(res, status, code, message) {
res.status(status).json({ ok: false, error: { code, message } });
}
// 列表 + 未读数
router.get('/notifications', async (req, res) => {
try {
const limit = Math.min(Number(req.query.limit) || 50, 200);
const onlyUnread = req.query.unread === '1';
const where = onlyUnread ? 'WHERE is_read = 0' : '';
const rows = await db().all(`SELECT id, type, title, body, link, severity, is_read, created_at FROM notifications ${where} ORDER BY created_at DESC LIMIT ?`, [limit]);
const unread = await db().get('SELECT COUNT(*) AS n FROM notifications WHERE is_read = 0');
ok(res, { items: rows, unread: unread?.n || 0 });
} catch (e) {
fail(res, 500, 'NOTIF_ERR', e.message);
}
});
// 标记已读
router.post('/notifications/read', async (req, res) => {
try {
const { id, all } = req.body || {};
if (all) {
await db().run('UPDATE notifications SET is_read = 1 WHERE is_read = 0');
} else if (id) {
await db().run('UPDATE notifications SET is_read = 1 WHERE id = ?', [id]);
}
ok(res, { updated: true });
} catch (e) {
fail(res, 500, 'NOTIF_ERR', e.message);
}
});
// 创建一条(内部或外部调用,开放 POST 方便前端调试/AI OCR 完成回写)
router.post('/notifications', async (req, res) => {
try {
const b = req.body || {};
if (!b.title) return fail(res, 400, 'BAD_INPUT', 'title 必填');
const r = await db().run(
`INSERT INTO notifications (type, title, body, link, severity, is_read) VALUES (?, ?, ?, ?, ?, 0)`,
[b.type || 'system', b.title, b.body || null, b.link || null, b.severity || 'info']
);
ok(res, { id: Number(r.lastInsertRowid) });
} catch (e) {
fail(res, 500, 'NOTIF_ERR', e.message);
}
});
// 工具:给其他模块调用
export async function pushNotification({ type = 'system', title, body = null, link = null, severity = 'info' } = {}) {
try {
await db().run(
`INSERT INTO notifications (type, title, body, link, severity, is_read) VALUES (?, ?, ?, ?, ?, 0)`,
[type, title, body, link, severity]
);
} catch (e) {
console.error('[notif] push failed:', e.message);
}
}
export default router;
+131
View File
@@ -0,0 +1,131 @@
// server/src/routes/operationLogs.js — 操作日志查询(只读)+ 恢复
import { Router } from 'express';
import { db } from '../db.js';
import { logOperation } from '../services/operationLog.js';
const router = Router();
function ok(res, data) { res.json(data); }
function fail(res, status, code, message, extra) {
res.status(status).json({ ok: false, error: { code, message, ...(extra || {}) } });
}
const ACTION_LABEL = { delete: '删除', batch_delete: '批量删除', create: '新建', update: '更新' };
const TARGET_LABEL = {
wash_record: '洗车记录', chemical: '用品', vehicle: '车辆',
maintenance: '保养记录', refuel: '加油记录', charging: '充电记录', insurance: '保险记录',
};
function fmt(row) {
let targetIds = [];
try { targetIds = JSON.parse(row.target_ids || '[]'); } catch { targetIds = []; }
let detail = null;
try { detail = row.detail_json ? JSON.parse(row.detail_json) : null; } catch { detail = null; }
return {
...row, target_ids: targetIds, detail,
action_label: ACTION_LABEL[row.action] || row.action,
target_label: TARGET_LABEL[row.target_type] || row.target_type,
recoverable: (row.action === 'delete' || row.action === 'batch_delete') && !row.recovered_at,
};
}
// GET /api/operation-logs
router.get('/operation-logs', async (req, res) => {
const { action, target_type, username, from, to } = req.query;
const page = Math.max(1, parseInt(req.query.page || '1'));
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit || '50')));
const offset = (page - 1) * limit;
const where = [], params = [];
if (action) { where.push('action = ?'); params.push(action); }
if (target_type) { where.push('target_type = ?'); params.push(target_type); }
if (username) { where.push('username = ?'); params.push(username); }
if (from) { where.push('created_at >= ?'); params.push(from + ' 00:00:00'); }
if (to) { where.push('created_at <= ?'); params.push(to + ' 23:59:59'); }
const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : '';
const rows = await db().all(`SELECT id, user_id, username, action, target_type, target_ids, target_summary,
detail_json, ip, user_agent, created_at, recovered_at
FROM operation_logs ${whereSql} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`,
[...params, limit, offset]);
const total = (await db().get(`SELECT COUNT(*) AS c FROM operation_logs ${whereSql}`, params))?.c || 0;
const stats = await db().all(`SELECT action, COUNT(*) AS c FROM operation_logs ${whereSql} GROUP BY action`, params);
ok(res, {
rows: rows.map(fmt), total, page, limit,
total_pages: Math.ceil(total / limit),
from, to, action, target_type, username,
stats: { by_action: stats },
});
});
// GET /api/operation-logs/options
router.get('/operation-logs/options', async (req, res) => {
ok(res, {
actions: Object.entries(ACTION_LABEL).map(([v, l]) => ({ value: v, label: l })),
target_types: Object.entries(TARGET_LABEL).map(([v, l]) => ({ value: v, label: l })),
});
});
// GET /api/operation-logs/:id
router.get('/operation-logs/:id', async (req, res) => {
const row = await db().get(`SELECT id, user_id, username, action, target_type, target_ids, target_summary,
detail_json, ip, user_agent, created_at, recovered_at
FROM operation_logs WHERE id = ?`, [req.params.id]);
if (!row) return fail(res, 404, 'NOT_FOUND', '日志不存在');
ok(res, fmt(row));
});
// POST /api/operation-logs/:id/recover
router.post('/operation-logs/:id/recover', async (req, res) => {
const row = await db().get('SELECT * FROM operation_logs WHERE id = ?', [req.params.id]);
if (!row) return fail(res, 404, 'NOT_FOUND', '日志不存在');
if (!['delete', 'batch_delete'].includes(row.action)) return fail(res, 422, 'NOT_RECOVERABLE', '该操作不可恢复');
if (row.recovered_at) return fail(res, 409, 'ALREADY_RECOVERED', '该记录已恢复过');
let detail = null;
try { detail = row.detail_json ? JSON.parse(row.detail_json) : null; } catch { detail = null; }
if (!detail) return fail(res, 422, 'NO_SNAPSHOT', '无快照数据,无法恢复');
const tableMap = {
wash_record: 'wash_records', vehicle: 'vehicles', maintenance: 'maintenance_records',
refuel: 'refuel_records', charging: 'charging_records', insurance: 'insurance_records',
};
const table = tableMap[row.target_type];
if (!table) return fail(res, 422, 'UNKNOWN_TYPE', `不支持恢复类型:${row.target_type}`);
let targetIds = [];
try { targetIds = JSON.parse(row.target_ids || '[]'); } catch { targetIds = []; }
if (row.action === 'delete') {
const snap = detail.snapshot || detail.record || detail.vehicle || null;
if (!snap) return fail(res, 422, 'NO_SNAPSHOT', '快照格式异常');
const id = snap.id || targetIds[0];
if (!id) return fail(res, 422, 'NO_ID', '快照无 id');
const upd = await db().run(`UPDATE ${table} SET is_deleted = 0, updated_at = NOW() WHERE id = ? AND is_deleted = 1`, [id]);
if (upd.changes === 0) return fail(res, 404, 'NOT_FOUND', '记录不存在或已恢复');
if (row.target_type === 'wash_record') {
await db().run('UPDATE chemical_usage SET is_deleted = 0 WHERE wash_record_id = ?', [id]);
}
} else {
const snaps = detail.snapshots || [];
for (const snap of snaps) {
const id = snap.id || null;
if (!id) continue;
await db().run(`UPDATE ${table} SET is_deleted = 0, updated_at = NOW() WHERE id = ? AND is_deleted = 1`, [id]);
if (row.target_type === 'wash_record') {
await db().run('UPDATE chemical_usage SET is_deleted = 0 WHERE wash_record_id = ?', [id]);
}
}
}
await db().run("UPDATE operation_logs SET recovered_at = NOW() WHERE id = ?", [row.id]);
logOperation({
req, action: 'recover', targetType: row.target_type, targetIds,
summary: `恢复 ${TARGET_LABEL[row.target_type] || row.target_type}${targetIds.length} 条)`,
detail: { recovered_from: row.id, target_ids: targetIds },
});
ok(res, { recovered: true, target_type: row.target_type, target_ids: targetIds });
});
export default router;
+745
View File
@@ -0,0 +1,745 @@
// server/src/routes/settings.js — 配置 + 概览统计
import { Router } from 'express';
import { db } from '../db.js';
import { buildExcel, buildPdf } from '../services/monthlyReport.js';
const router = Router();
function ok(res, data) {
res.json(data);
}
function fail(res, status, code, message, extra) {
res.status(status).json({ ok: false, error: { code, message, ...(extra || {}) } });
}
// GET /api/settings
router.get('/settings', async (req, res) => {
const rows = await db().all('SELECT `key`, value, is_secret FROM settings');
const config = {};
for (const r of rows) {
if (r.is_secret) {
// 敏感字段:只返回是否已配置
config[r.key] = r.value ? '••••••' : '';
} else {
config[r.key] = r.value;
}
}
ok(res, config);
});
// POST /api/settings body: { group, settings: {...} }
// group: weather / grocy / app / auth / session
router.post('/settings', async (req, res) => {
const b = req.body || {};
if (!b.group || typeof b.settings !== 'object') {
return fail(res, 400, 'BAD_REQUEST', 'group 和 settings 必填');
}
const updates = [];
for (const [k, v] of Object.entries(b.settings)) {
updates.push({ key: k, value: v == null ? '' : String(v) });
}
for (const u of updates) {
// 已知需要保密的字段
const isSecret = ['grocy_password', 'grocy_username', 'ai_api_key'].includes(u.key) ? 1 : 0;
await db().run(
`INSERT INTO settings (\`key\`, value, is_secret, description, updated_at)
VALUES (?, ?, ?, '', NOW())
ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = NOW()`,
[u.key, u.value, isSecret]
);
}
ok(res, { group: b.group, updated: updates.length });
});
// GET /api/stats/overview — 概览页用(优化:30 天频次单 SQL + 独立查询并行)
/**
* @openapi
* /api/stats/overview:
* get:
* tags: [settings]
* summary: 总览数据(今日/30 天/月度)
*/
router.get('/stats/overview', async (req, res) => {
const today = new Date().toISOString().slice(0, 10);
const monthStart = today.slice(0, 7) + '-01';
const lastMonthStart = new Date(new Date(monthStart).getTime() - 86400 * 1000)
.toISOString()
.slice(0, 7) + '-01';
// 并行 1:基础计数 + 30 天频次单 SQL 聚合(替代原 30 次串行)
const day30 = isoDaysAgo(30);
const day90 = isoDaysAgo(90);
const [
totalRow,
totalCostRow,
lastDateRow,
firstDateRow,
thisMonthRow,
thisMonthCostRow,
lastMonthRow,
lastMonthCostRow,
activeVehiclesRow,
totalVehiclesRow,
freq30dRows,
type_dist,
] = await Promise.all([
db().get('SELECT COUNT(*) c FROM wash_records WHERE is_deleted = 0'),
db().get('SELECT ROUND(SUM(cost), 2) c FROM wash_records WHERE is_deleted = 0'),
db().get('SELECT MAX(wash_date) d FROM wash_records WHERE is_deleted = 0'),
db().get('SELECT MIN(wash_date) d FROM wash_records WHERE is_deleted = 0'),
db().get(
'SELECT COUNT(*) c FROM wash_records WHERE is_deleted = 0 AND wash_date >= ?',
[monthStart]
),
db().get(
'SELECT ROUND(SUM(cost), 2) c FROM wash_records WHERE is_deleted = 0 AND wash_date >= ?',
[monthStart]
),
db().get(
'SELECT COUNT(*) c FROM wash_records WHERE is_deleted = 0 AND wash_date >= ? AND wash_date < ?',
[lastMonthStart, monthStart]
),
db().get(
'SELECT ROUND(SUM(cost), 2) c FROM wash_records WHERE is_deleted = 0 AND wash_date >= ? AND wash_date < ?',
[lastMonthStart, monthStart]
),
db().get('SELECT COUNT(*) c FROM vehicles WHERE is_active = 1 AND is_deleted = 0'),
db().get('SELECT COUNT(*) c FROM vehicles WHERE is_deleted = 0'),
// 30 天频次:1 条 SQL 拿到每日 countJS 端补 0
db().all(
`SELECT wash_date AS date, COUNT(*) AS count
FROM wash_records
WHERE is_deleted = 0 AND wash_date >= ?
GROUP BY wash_date`,
[day30]
),
// 类型分布(90 天)
db().all(
`SELECT wash_type AS type, COUNT(*) AS count
FROM wash_records WHERE is_deleted = 0 AND wash_date >= ?
GROUP BY wash_type ORDER BY count DESC`,
[day90]
),
]);
const total_washes = totalRow?.c || 0;
const total_cost = totalCostRow?.c || 0;
const last_wash_date = lastDateRow?.d || null;
const days_since_last = last_wash_date
? Math.max(0, Math.floor((Date.now() - new Date(last_wash_date).getTime()) / 86400000))
: null;
const first_wash_date = firstDateRow?.d || null;
const days = first_wash_date
? Math.max(1, Math.ceil((Date.now() - new Date(first_wash_date).getTime()) / 86400000))
: 1;
const avg_per_month = Math.round((total_washes / days) * 30 * 10) / 10;
const washes_this_month = thisMonthRow?.c || 0;
const cost_this_month = thisMonthCostRow?.c || 0;
const washes_last_month = lastMonthRow?.c || 0;
const cost_last_month = lastMonthCostRow?.c || 0;
const washes_change =
washes_last_month > 0
? Math.round(((washes_this_month - washes_last_month) / washes_last_month) * 100)
: null;
const cost_change =
cost_last_month > 0
? Math.round(((cost_this_month - cost_last_month) / cost_last_month) * 100)
: null;
const avg_interval_days =
total_washes > 1
? Math.round((Date.now() - new Date(first_wash_date).getTime()) / 86400000 / (total_washes - 1))
: null;
const active_vehicles = activeVehiclesRow?.c || 0;
const total_vehicles = totalVehiclesRow?.c || 0;
// 30 天频次:JS 端补齐缺失的日期
const freqMap = new Map(freq30dRows.map((r) => [r.date, r.count]));
const freq_30d = [];
for (let i = 29; i >= 0; i--) {
const d = new Date(Date.now() - i * 86400 * 1000).toISOString().slice(0, 10);
freq_30d.push({ date: d.slice(5), count: freqMap.get(d) || 0 });
}
// 12 月趋势 + 车辆 breakdown + 化学品 top + 低库存:一次性并行
const totalCostForPct = Math.max(total_cost, 1);
const [monthlyFreqRows, monthlyCostRows, vehicle_breakdown, chemical_top, low_stock_products] =
await Promise.all([
db().all(`
SELECT SUBSTRING(wash_date, 1, 7) AS month, COUNT(*) AS count
FROM wash_records WHERE is_deleted = 0
GROUP BY SUBSTRING(wash_date, 1, 7)
ORDER BY month DESC LIMIT 12
`),
db().all(`
SELECT SUBSTRING(wash_date, 1, 7) AS month, ROUND(SUM(cost), 2) AS cost
FROM wash_records WHERE is_deleted = 0
GROUP BY SUBSTRING(wash_date, 1, 7)
ORDER BY month DESC LIMIT 12
`),
db().all(
`
SELECT v.id, v.name, v.plate, v.type,
COUNT(w.id) AS count,
ROUND(SUM(w.cost), 2) AS cost,
ROUND(SUM(w.cost) * 100.0 / ?, 1) AS pct
FROM vehicles v
LEFT JOIN wash_records w ON w.vehicle_id = v.id AND w.is_deleted = 0
WHERE v.is_deleted = 0
GROUP BY v.id ORDER BY cost DESC
`,
[totalCostForPct]
),
db().all(`
SELECT c.grocy_product_id, c.name, c.unit,
SUM(cu.amount) AS total_amount,
COUNT(*) AS count
FROM chemical_usage cu
JOIN chemicals c ON c.grocy_product_id = cu.chemical_id
WHERE cu.is_deleted = 0
GROUP BY c.grocy_product_id
ORDER BY total_amount DESC LIMIT 5
`),
db().all(`
SELECT grocy_product_id, name, current_amount, min_stock_amount, unit, category, location
FROM chemicals
WHERE is_active = 1
AND source = 'grocy'
AND min_stock_amount > 0
AND current_amount <= min_stock_amount
ORDER BY (current_amount - min_stock_amount) ASC
LIMIT 20
`),
]);
const monthly_freq = monthlyFreqRows.reverse();
const monthly_cost = monthlyCostRows.reverse();
ok(res, {
overview: {
total_washes,
total_cost,
days_since_last,
last_wash_date,
avg_per_month,
washes_this_month,
cost_this_month,
washes_change,
cost_change,
avg_interval_days,
active_vehicles,
total_vehicles,
},
freq_30d,
type_dist,
monthly_freq,
monthly_cost,
vehicle_breakdown,
chemical_top,
low_stock_products,
});
});
// GET /api/dashboard/extra — 概览页额外信息(天气 + config)
router.get('/dashboard/extra', async (req, res) => {
const today = new Date().toISOString().slice(0, 10);
const weather =
(await db().get(
`
SELECT * FROM weather_snapshots WHERE snapshot_date = ?
ORDER BY fetched_at DESC LIMIT 1
`,
[today]
)) || null;
const cfg = {
app: { city: await get('app_city', 'auto') },
grocy: {
url: await get('grocy_url', ''),
has_username: !!(await get('grocy_username', '')),
},
};
ok(res, { weather, config: cfg });
});
// GET /api/stats/extra — 3 个真正有用的可视化:
/**
* @openapi
* /api/stats/extra:
* get:
* tags: [settings]
* summary: 油价趋势 / 年均养护成本 / 洗车季节频率(3 个图表数据)
* responses:
* 200:
* description: 3 个数组 fuelTrend / costPerVehicle / washSeason
*/
router.get('/stats/extra', async (req, res) => {
try {
// 1) 油价趋势:按月聚合 refuel_records 的 total_cost/liters(没有 unit_price 列)
const fuelTrend = await db().all(
`SELECT
substr(refuel_date, 1, 7) AS ym,
ROUND(AVG(CASE WHEN liters > 0 THEN total_cost / liters END), 3) AS derived_unit_price,
COUNT(*) AS cnt,
ROUND(SUM(total_cost), 2) AS total_amount,
ROUND(SUM(liters), 2) AS total_liters
FROM refuel_records
WHERE is_deleted = 0 AND liters > 0
GROUP BY ym
ORDER BY ym ASC
LIMIT 24`
);
// 2) 每辆车年均养护成本:洗车+加油+充电+保养+保险 / 持有天数 * 365
const costPerVehicle = await db().all(
`WITH owned AS (
SELECT id, name, plate, created_at AS owned_from
FROM vehicles
WHERE is_active = 1
),
days_owned AS (
SELECT id, name, plate,
GREATEST(1, DATEDIFF(CURDATE(), DATE(owned_from))) AS days
FROM owned
),
per_cat AS (
SELECT vehicle_id, SUM(cost) AS c FROM wash_records WHERE vehicle_id IS NOT NULL AND is_deleted = 0 GROUP BY vehicle_id
UNION ALL
SELECT vehicle_id, SUM(total_cost) AS c FROM refuel_records WHERE vehicle_id IS NOT NULL AND is_deleted = 0 GROUP BY vehicle_id
UNION ALL
SELECT vehicle_id, SUM(total_cost) AS c FROM charging_records WHERE vehicle_id IS NOT NULL AND is_deleted = 0 GROUP BY vehicle_id
UNION ALL
SELECT vehicle_id, SUM(total_cost) AS c FROM maintenance_records WHERE vehicle_id IS NOT NULL AND is_deleted = 0 GROUP BY vehicle_id
UNION ALL
SELECT vehicle_id, SUM(premium) AS c FROM insurance_records WHERE vehicle_id IS NOT NULL AND is_deleted = 0 GROUP BY vehicle_id
),
per_vehicle AS (
SELECT vehicle_id, SUM(c) AS total_cost FROM per_cat GROUP BY vehicle_id
)
SELECT v.id, v.name, v.plate,
d.days AS days_owned,
ROUND(COALESCE(pv.total_cost, 0), 2) AS lifetime_cost,
ROUND(COALESCE(pv.total_cost, 0) * 365.0 / d.days, 2) AS annual_cost
FROM days_owned d
JOIN vehicles v ON v.id = d.id
LEFT JOIN per_vehicle pv ON pv.vehicle_id = v.id
ORDER BY annual_cost DESC
LIMIT 20`
);
// 3) 洗车频率 vs 季节:按月聚合 wash 数量 + 月平均花费
const washSeason = await db().all(
`SELECT
ym,
mo AS month,
COUNT(*) AS cnt,
ROUND(AVG(cost), 2) AS avg_cost,
ROUND(SUM(cost), 2) AS total_cost
FROM (
SELECT substr(wash_date, 1, 7) AS ym,
CAST(substr(wash_date, 6, 2) AS UNSIGNED) AS mo,
cost
FROM wash_records
WHERE is_deleted = 0
) t
GROUP BY ym, mo
ORDER BY ym ASC
LIMIT 24`
);
// settings.js 的 ok() helper 不包装成 {ok, data},这里手动包一下让前端 axios interceptor 能解包
res.json({ ok: true, data: { fuelTrend, costPerVehicle, washSeason } });
} catch (e) {
fail(res, 500, 'STATS_ERR', e.message);
}
});
// GET /api/settings/city — 当前城市信息(供设置页显示)
router.get('/settings/city', async (req, res) => {
const today = new Date().toISOString().slice(0, 10);
const savedCity = await get('app_city', 'auto');
const defaultCity = await get('app_city_default', '');
const cityRow = await db().get("SELECT updated_at FROM settings WHERE `key` = 'app_city'");
const setDate = cityRow?.updated_at ? new Date(cityRow.updated_at).toLocaleDateString('en-CA') : null;
const isAutoToday = !savedCity || savedCity === 'auto' || setDate !== today;
ok(res, { saved_city: savedCity, default_city: defaultCity, is_auto_today: isAutoToday, saved_at: setDate });
});
// GET /api/settings/weather — 获取今日天气(当天已缓存则直读 DB,不重复请求 wttr)
router.get('/settings/weather', async (req, res) => {
const today = new Date().toISOString().slice(0, 10);
// 先查 DB,当天的直接返回
const cached = await db().get(
'SELECT * FROM weather_snapshots WHERE snapshot_date = ? ORDER BY fetched_at DESC LIMIT 1',
[today]
);
if (cached) {
return ok(res, { ...cached, from_cache: true });
}
// 没有当天缓存,再请求 wttr(带保护)
try {
const { fetchToday } = await import('../services/weather.js');
const city = await get('app_city', 'auto');
const w = await fetchToday(city, null);
// 写入 DB
const exist = await db().get('SELECT id FROM weather_snapshots WHERE city = ? AND snapshot_date = ?', [
w.city,
today,
]);
if (exist) {
await db().run(
`UPDATE weather_snapshots SET provider=?, temp_c=?, humidity=?, weather_desc=?, weather_code=?, wind_kph=?, precip_mm=?, raw_json=?, fetched_at=NOW() WHERE id=?`,
[
'wttr',
w.temp_c,
w.humidity,
w.weather_desc,
w.weather_code,
w.wind_kph,
w.precip_mm,
w.raw_json,
exist.id,
]
);
} else {
await db().run(
`INSERT INTO weather_snapshots (city, provider, temp_c, humidity, weather_desc, weather_code, wind_kph, precip_mm, raw_json, snapshot_date) VALUES (?, 'wttr', ?, ?, ?, ?, ?, ?, ?, ?)`,
[
w.city,
w.temp_c,
w.humidity,
w.weather_desc,
w.weather_code,
w.wind_kph,
w.precip_mm,
w.raw_json,
today,
]
);
}
ok(res, { ...w, from_cache: false });
} catch (e) {
fail(res, 502, 'WEATHER_FETCH_FAILED', e.message);
}
});
// GET /api/settings/grocy-logs — Grocy 同步历史(供设置页显示)
router.get('/settings/grocy-logs', async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 20, 100);
const rows = await db().all(
`
SELECT id, action, status, ok_count, fail_count, detail, started_at, finished_at
FROM grocy_sync_logs ORDER BY started_at DESC LIMIT ?
`,
[limit]
);
ok(
res,
rows.map((r) => ({
...r,
detail: r.detail ? JSON.parse(r.detail) : null,
}))
);
});
// POST /api/settings/reset — 重置所有业务数据(需确认码)
router.post('/settings/reset', async (req, res) => {
const { confirm_token, seed } = req.body || {};
// 简单确认码:固定 "RESET-ALL-DATA"
const EXPECTED = 'RESET-ALL-DATA';
if (confirm_token !== EXPECTED) {
return fail(res, 403, 'INVALID_TOKEN', '确认码错误,请输入 RESET-ALL-DATA');
}
const TABLES = [
'operation_logs',
'chemical_usage',
'chemical_inventory_log',
'weather_snapshots',
'charging_records',
'refuel_records',
'maintenance_records',
'insurance_records',
'wash_records',
'chemicals',
'vehicles',
'grocy_sync_logs',
];
for (const t of TABLES) await db().run(`DELETE FROM \`${t}\``);
let stats = {};
if (seed) {
const { randomUUID } = await import('node:crypto');
const today = new Date().toISOString().slice(0, 10);
const ago = (d) => new Date(Date.now() - d * 86400 * 1000).toISOString().slice(0, 10);
const mkuid = (p) => p + '-' + randomUUID().replace(/-/g, '').slice(0, 8);
const vehicleDefs = [
{
name: '我的 Tiguan',
plate: '粤B12345',
type: 'suv',
color: '黑色',
powertrain: 'hev',
notes: '镀晶车 · 2023 款',
},
{ name: '领导的爱车', plate: '粤B67890', type: 'car', color: '白色', powertrain: 'ice', notes: '日常代步' },
];
const vehicleIds = [];
for (const v of vehicleDefs) {
const info = await db().run(
`INSERT INTO vehicles (name, plate, type, color, powertrain, notes, is_active, sort_order)
VALUES (?, ?, ?, ?, ?, ?, 1, ?)`,
[v.name, v.plate, v.type, v.color, v.powertrain, v.notes, vehicleIds.length]
);
vehicleIds.push(Number(info.lastInsertRowid));
}
const chemDefs = [
{ name: 'Adams Q2M BIE', category: '洗车液', unit: '瓶', amount: 3, value: 450, minAmt: 2, loc: '工具箱' },
{ name: 'Adams Q2M WASH', category: '洗车液', unit: '瓶', amount: 2, value: 296, minAmt: 2, loc: '工具箱' },
{
name: 'Adams Q2M HD CURE',
category: '养护剂',
unit: '瓶',
amount: 2,
value: 396,
minAmt: 1,
loc: '工具箱',
},
{
name: 'Adams Detail Spray',
category: '养护剂',
unit: '瓶',
amount: 2,
value: 180,
minAmt: 2,
loc: '工具箱',
},
{ name: '化学小子金融士', category: '美容剂', unit: '罐', amount: 1, value: 280, minAmt: 1, loc: '储物柜' },
{ name: 'DetailQ 收边毛巾', category: '工具', unit: '条', amount: 8, value: 240, minAmt: 5, loc: '毛巾架' },
{ name: '化学小子脱水毛巾', category: '工具', unit: '条', amount: 5, value: 150, minAmt: 3, loc: '毛巾架' },
{ name: 'Gyeon Q2M FOAM', category: '洗车液', unit: '瓶', amount: 1, value: 168, minAmt: 1, loc: '工具箱' },
];
for (const c of chemDefs) {
await db().run(
`INSERT INTO chemicals (grocy_product_id, name, category, unit, current_amount, current_value, min_stock_amount, location, source, is_active, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'manual', 1, NOW())`,
[mkuid('chem'), c.name, c.category, c.unit, c.amount, c.value, c.minAmt, c.loc]
);
}
const washTypes = ['quick', 'full', 'detail'];
const washLocs = ['自家', '自家', '外面', '外面'];
let washCount = 0;
for (const vid of vehicleIds) {
for (let d = 90; d >= 0; d -= Math.floor(10 + Math.random() * 15)) {
const wt = washTypes[Math.floor(Math.random() * washTypes.length)];
const cost = wt === 'quick' ? 80 : wt === 'full' ? 120 : 280;
await db().run(
`INSERT INTO wash_records (vehicle_id, wash_date, wash_type, location, cost, notes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())`,
[vid, ago(d), wt, washLocs[Math.floor(Math.random() * washLocs.length)], cost, '']
);
washCount++;
}
}
const maintDefs = [
{
vid: vehicleIds[0],
date: ago(60),
odo: 15000,
shop: '4S店',
cost: 850,
items: '["机油","机滤","空滤"]',
notes: '首保',
},
{
vid: vehicleIds[0],
date: ago(30),
odo: 15650,
shop: '途虎',
cost: 420,
items: '["机油","机滤"]',
notes: '二保',
},
{
vid: vehicleIds[1],
date: ago(90),
odo: 8000,
shop: '途虎',
cost: 380,
items: '["机油","机滤","空调滤"]',
notes: '常规保养',
},
];
for (const m of maintDefs) {
await db().run(
`INSERT INTO maintenance_records (vehicle_id, maint_date, odometer_km, shop, total_cost, items_json, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`,
[m.vid, m.date, m.odo, m.shop, m.cost, m.items, m.notes]
);
}
const fuelTypes = ['92#', '95#', '98#'];
const stations = ['中石化', '中石油', '壳牌', '民营油站'];
for (const vid of vehicleIds) {
let odo = vid === vehicleIds[0] ? 14000 : 8000;
for (let d = 60; d >= 0; d -= Math.floor(5 + Math.random() * 5)) {
const liters = 40 + Math.random() * 20;
const price = 7.5 + Math.random() * 1.0;
odo += Math.floor(400 + Math.random() * 200);
await db().run(
`INSERT INTO refuel_records (vehicle_id, refuel_date, odometer_km, fuel_type, liters, price_per_liter, is_full, total_cost, station, created_at)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, NOW())`,
[
vid,
ago(d),
odo,
fuelTypes[Math.floor(Math.random() * fuelTypes.length)],
Math.round(liters * 10) / 10,
Math.round(price * 100) / 100,
Math.round(liters * price * 10) / 10,
stations[Math.floor(Math.random() * stations.length)],
]
);
}
}
for (const vid of vehicleIds) {
let odo = vid === vehicleIds[0] ? 14200 : 8200;
for (let d = 45; d >= 0; d -= Math.floor(7 + Math.random() * 7)) {
const kwh = 15 + Math.random() * 15;
const price = Math.random() > 0.5 ? 0.5 : 1.5;
const sSoc = Math.floor(20 + Math.random() * 20);
const eSoc = sSoc + Math.floor(30 + Math.random() * 40);
const ctype = Math.random() > 0.6 ? 'public' : 'home';
odo += Math.floor(80 + Math.random() * 120);
await db().run(
`INSERT INTO charging_records (vehicle_id, charge_date, odometer_km, charge_type, kwh, price_per_kwh, total_cost, station, start_soc, end_soc, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())`,
[
vid,
ago(d),
odo,
ctype,
Math.round(kwh * 10) / 10,
Math.round(price * 100) / 100,
Math.round(kwh * price * 100) / 100,
ctype === 'home' ? '自家桩' : '快充站',
sSoc,
Math.min(eSoc, 100),
ctype === 'home' ? '谷时充电' : '',
]
);
}
}
const insTypes = ['交强险', '商业险', '三者险'];
const insurers = ['平安保险', '太平洋保险', '人保'];
for (const vid of vehicleIds) {
for (let m = 12; m >= 0; m -= 12) {
const start = new Date(Date.now() - m * 30 * 86400 * 1000);
const end = new Date(start.getTime() + 365 * 86400 * 1000);
await db().run(
`INSERT INTO insurance_records (vehicle_id, insurance_type, company, policy_no, premium, start_date, end_date, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`,
[
vid,
insTypes[Math.floor(Math.random() * insTypes.length)],
insurers[Math.floor(Math.random() * insurers.length)],
'POL' + Math.floor(1e9 + Math.random() * 9e9),
950 + Math.floor(Math.random() * 3500),
start.toISOString().slice(0, 10),
end.toISOString().slice(0, 10),
]
);
}
}
stats = {
vehicles: vehicleIds.length,
chemicals: chemDefs.length,
washes: washCount,
maint: maintDefs.length,
};
}
ok(res, {
ok: true,
message: seed ? '✅ 数据已重置并灌入演示数据,请刷新页面' : '✅ 业务数据已清空',
stats,
});
});
async function get(key, fallback) {
const row = await db().get('SELECT value FROM settings WHERE `key` = ?', [key]);
if (!row) return fallback;
return row.value || fallback;
}
function isoDaysAgo(d) {
return new Date(Date.now() - d * 86400 * 1000).toISOString().slice(0, 10);
}
// ====== 月度报表(Excel + PDF======
// GET /api/reports/monthly/excel?month=YYYY-MM
/**
* @openapi
* /api/reports/monthly/excel:
* get:
* tags: [settings]
* summary: 月度报表 Excel?month=YYYY-MM
* /api/reports/monthly/pdf:
* get:
* tags: [settings]
* summary: 月度报表 PDF?month=YYYY-MM
* /api/reports/monthly/list:
* get:
* tags: [settings]
* summary: 列出过去 N 个月(前端下拉用)
*/
router.get('/reports/monthly/excel', async (req, res) => {
try {
const month = String(req.query.month || new Date().toISOString().slice(0, 7));
if (!/^\d{4}-\d{2}$/.test(month)) return fail(res, 422, 'VALIDATION', 'month 必须是 YYYY-MM');
const buf = await buildExcel(month);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="carwash-${month}.xlsx"`);
res.send(buf);
} catch (e) {
fail(res, 500, 'EXPORT_FAIL', e.message);
}
});
// GET /api/reports/monthly/pdf?month=YYYY-MM
router.get('/reports/monthly/pdf', async (req, res) => {
try {
const month = String(req.query.month || new Date().toISOString().slice(0, 7));
if (!/^\d{4}-\d{2}$/.test(month)) return fail(res, 422, 'VALIDATION', 'month 必须是 YYYY-MM');
const buf = await buildPdf(month);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="carwash-${month}.pdf"`);
res.send(buf);
} catch (e) {
fail(res, 500, 'EXPORT_FAIL', e.message);
}
});
// GET /api/reports/monthly/list?limit=12 — 列出过去 N 个月(前端下拉用)
router.get('/reports/monthly/list', async (req, res) => {
const limit = Math.min(24, Math.max(1, parseInt(req.query.limit || '12')));
const months = [];
const now = new Date();
// 用 UTC 方法构造,避免本地时区 (e.g. CST UTC+8) 在跨月时导致 toISOString 切片错位
for (let i = 0; i < limit; i++) {
const y = now.getUTCFullYear();
const m = now.getUTCMonth() - i;
// 处理负数月份:JS Date 构造函数能自动 roll over
const d = new Date(Date.UTC(y, m, 1));
months.push(d.toISOString().slice(0, 7));
}
ok(res, { months });
});
export default router;
+112
View File
@@ -0,0 +1,112 @@
// server/src/routes/tags.js — 标签系统
import { Router } from 'express';
import { db } from '../db.js';
const router = Router();
function ok(res, data) { res.json({ ok: true, data }); }
function fail(res, status, code, message) {
res.status(status).json({ ok: false, error: { code, message } });
}
const RECORD_TYPES = ['wash', 'refuel', 'charge', 'maintenance', 'insurance'];
// 列出所有标签 + 各自被用了多少次
router.get('/tags', async (req, res) => {
try {
const rows = await db().all(`
SELECT t.id, t.name, t.color, t.created_at,
(SELECT COUNT(*) FROM record_tags rt WHERE rt.tag_id = t.id) AS use_count
FROM tags t
ORDER BY use_count DESC, t.name ASC
`);
ok(res, { items: rows });
} catch (e) {
fail(res, 500, 'TAG_ERR', e.message);
}
});
// 创建
router.post('/tags', async (req, res) => {
try {
const b = req.body || {};
const name = (b.name || '').trim();
if (!name) return fail(res, 400, 'BAD_INPUT', 'name 必填');
const r = await db().run('INSERT INTO tags (name, color) VALUES (?, ?)', [name, b.color || null]);
ok(res, { id: Number(r.lastInsertRowid), name, color: b.color || null });
} catch (e) {
if (String(e.message).includes('Duplicate')) return fail(res, 409, 'EXISTS', '标签已存在');
fail(res, 500, 'TAG_ERR', e.message);
}
});
// 删除标签(级联清掉 record_tags
router.delete('/tags/:id', async (req, res) => {
try {
await db().run('DELETE FROM record_tags WHERE tag_id = ?', [req.params.id]);
await db().run('DELETE FROM tags WHERE id = ?', [req.params.id]);
ok(res, { deleted: true });
} catch (e) {
fail(res, 500, 'TAG_ERR', e.message);
}
});
// 给记录打/卸标签
router.post('/record_tags', async (req, res) => {
try {
const b = req.body || {};
if (!RECORD_TYPES.includes(b.record_type)) return fail(res, 400, 'BAD_TYPE', 'record_type 不合法');
if (!b.record_id || !b.tag_id) return fail(res, 400, 'BAD_INPUT', 'record_id / tag_id 必填');
const exists = await db().get('SELECT id FROM record_tags WHERE record_type = ? AND record_id = ? AND tag_id = ?', [b.record_type, b.record_id, b.tag_id]);
if (exists) {
await db().run('DELETE FROM record_tags WHERE id = ?', [exists.id]);
return ok(res, { toggled: 'removed' });
} else {
const r = await db().run('INSERT INTO record_tags (record_type, record_id, tag_id) VALUES (?, ?, ?)', [b.record_type, b.record_id, b.tag_id]);
return ok(res, { toggled: 'added', id: Number(r.lastInsertRowid) });
}
} catch (e) {
fail(res, 500, 'TAG_ERR', e.message);
}
});
// 查某记录的标签
router.get('/record_tags', async (req, res) => {
try {
const { record_type, record_id } = req.query;
if (!record_type || !record_id) return fail(res, 400, 'BAD_INPUT', 'record_type / record_id 必填');
const rows = await db().all(`
SELECT t.id, t.name, t.color
FROM record_tags rt
JOIN tags t ON t.id = rt.tag_id
WHERE rt.record_type = ? AND rt.record_id = ?
ORDER BY t.name
`, [record_type, record_id]);
ok(res, { items: rows });
} catch (e) {
fail(res, 500, 'TAG_ERR', e.message);
}
});
// 通用筛:找打了某标签的记录
router.get('/tags/:id/records', async (req, res) => {
try {
const rows = await db().all(`
SELECT rt.record_type, rt.record_id
FROM record_tags rt
WHERE rt.tag_id = ?
ORDER BY rt.created_at DESC
LIMIT 500
`, [req.params.id]);
// 按 type 分组
const byType = {};
for (const r of rows) {
if (!byType[r.record_type]) byType[r.record_type] = [];
byType[r.record_type].push(r.record_id);
}
ok(res, { tag_id: Number(req.params.id), by_type: byType, total: rows.length });
} catch (e) {
fail(res, 500, 'TAG_ERR', e.message);
}
});
export default router;
+386
View File
@@ -0,0 +1,386 @@
// server/src/routes/vehicles.js — 车辆管理
import { Router } from 'express';
import { db } from '../db.js';
import { logOperation } from '../services/operationLog.js';
const router = Router();
function ok(res, data) {
res.json(data);
}
function fail(res, status, code, message, extra) {
res.status(status).json({ ok: false, error: { code, message, ...(extra || {}) } });
}
const TYPES = ['car', 'suv', 'mpv', 'truck', 'other'];
const POWERTRAINS = ['ice', 'hev', 'ev', 'erev'];
const POWERTRAIN_LABEL = { ice: '纯油', hev: '混动', ev: '纯电', erev: '增程' };
// GET /api/vehicles — 列表(带每辆车的统计)
/**
* @openapi
* /api/vehicles:
* get:
* tags: [vehicles]
* summary: 列出所有车辆
* post:
* tags: [vehicles]
* summary: 新建车辆
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name]
* properties:
* name: { type: string, maxLength: 64 }
* plate: { type: string }
* type: { type: string }
* color: { type: string }
*/
router.get('/vehicles', async (req, res) => {
const whereActive = req.query.active == 1;
const rows = await db().all(`
SELECT v.*,
COALESCE(s.wash_count, 0) AS wash_count,
COALESCE(s.total_cost, 0) AS total_cost,
s.last_wash_date
FROM vehicles v
LEFT JOIN (
SELECT vehicle_id,
COUNT(*) AS wash_count,
ROUND(SUM(cost), 2) AS total_cost,
MAX(wash_date) AS last_wash_date
FROM wash_records WHERE vehicle_id IS NOT NULL
GROUP BY vehicle_id
) s ON s.vehicle_id = v.id
${whereActive ? 'WHERE v.is_active = 1 AND v.is_deleted = 0' : 'WHERE v.is_deleted = 0'}
ORDER BY v.is_active DESC, v.sort_order ASC, v.id ASC
`);
ok(
res,
rows.map((r) => ({ ...r, powertrain_label: POWERTRAIN_LABEL[r.powertrain] || r.powertrain }))
);
});
// GET /api/vehicles/stats — 车辆总览(必须在 /vehicles/:id 之前注册)
/**
* @openapi
* /api/vehicles/stats:
* get:
* tags: [vehicles]
* summary: 车辆总览统计(总数 / 启用 / 有洗车记录)
*/
router.get('/vehicles/stats', async (req, res) => {
const total = (await db().get('SELECT COUNT(*) c FROM vehicles WHERE is_deleted = 0'))?.c || 0;
const active = (await db().get('SELECT COUNT(*) c FROM vehicles WHERE is_active = 1 AND is_deleted = 0'))?.c || 0;
const withWashes =
(
await db().get(
'SELECT COUNT(DISTINCT vehicle_id) c FROM wash_records WHERE vehicle_id IS NOT NULL AND is_deleted = 0'
)
)?.c || 0;
ok(res, { total, active, with_washes: withWashes });
});
// GET /api/vehicles/:id
router.get('/vehicles/:id', async (req, res) => {
const v = await db().get(
`
SELECT v.*,
COALESCE(s.wash_count, 0) AS wash_count,
COALESCE(s.total_cost, 0) AS total_cost,
s.last_wash_date
FROM vehicles v
LEFT JOIN (
SELECT vehicle_id, COUNT(*) AS wash_count, ROUND(SUM(cost), 2) AS total_cost, MAX(wash_date) AS last_wash_date
FROM wash_records WHERE vehicle_id IS NOT NULL GROUP BY vehicle_id
) s ON s.vehicle_id = v.id
WHERE v.id = ? AND v.is_deleted = 0`,
[req.params.id]
);
if (!v) return fail(res, 404, 'NOT_FOUND', '车辆不存在');
ok(res, { ...v, powertrain_label: POWERTRAIN_LABEL[v.powertrain] || v.powertrain });
});
// POST /api/vehicles
router.post('/vehicles', async (req, res) => {
const b = req.body || {};
const errors = {};
if (!b.name || b.name.length > 64) errors.name = '必填且 ≤ 64 字';
if (!TYPES.includes(b.type || 'car')) errors.type = 'car/suv/mpv/truck/other';
if (b.powertrain && !POWERTRAINS.includes(b.powertrain)) errors.powertrain = 'ice/hev/ev/erev';
if (Object.keys(errors).length) return fail(res, 422, 'VALIDATION', '校验失败', { errors });
if (b.plate) {
const dup = await db().get('SELECT id FROM vehicles WHERE plate = ?', [b.plate]);
if (dup) return fail(res, 409, 'CONFLICT', '该车牌已存在');
}
const info = await db().run(
`
INSERT INTO vehicles (name, plate, type, color, notes, is_active, sort_order, powertrain)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
b.name,
b.plate || null,
b.type || 'car',
b.color || null,
b.notes || null,
b.is_active === false ? 0 : 1,
b.sort_order || 0,
b.powertrain || 'ice',
]
);
ok(res, { id: Number(info.lastInsertRowid) });
});
// PUT /api/vehicles/:id
router.put('/vehicles/:id', async (req, res) => {
const b = req.body || {};
const v = await db().get('SELECT * FROM vehicles WHERE id = ? AND is_deleted = 0', [req.params.id]);
if (!v) return fail(res, 404, 'NOT_FOUND', '车辆不存在或已删除');
if (b.plate && b.plate !== v.plate) {
const dup = await db().get('SELECT id FROM vehicles WHERE plate = ? AND id != ?', [b.plate, v.id]);
if (dup) return fail(res, 409, 'CONFLICT', '该车牌已被其他车占用');
}
await db().run(
`
UPDATE vehicles
SET name = COALESCE(?, name),
plate = ?,
type = COALESCE(?, type),
color = ?,
notes = ?,
is_active = ?,
powertrain = COALESCE(?, powertrain),
current_km = ?,
updated_at = NOW()
WHERE id = ?`,
[
b.name || null,
b.plate ?? v.plate,
b.type || null,
b.color ?? v.color,
b.notes ?? v.notes,
b.is_active === false ? 0 : b.is_active === true ? 1 : v.is_active,
POWERTRAINS.includes(b.powertrain) ? b.powertrain : null,
b.current_km != null ? Number(b.current_km) : v.current_km,
v.id,
]
);
ok(res, { id: v.id, updated: true });
});
// DELETE /api/vehicles/:id — 软删(is_deleted=1+ 操作日志快照
router.delete('/vehicles/:id', async (req, res) => {
const v = await db().get('SELECT * FROM vehicles WHERE id = ? AND is_deleted = 0', [req.params.id]);
if (!v) return fail(res, 404, 'NOT_FOUND', '车辆不存在或已删除');
logOperation({
req,
action: 'delete',
targetType: 'vehicle',
targetIds: v.id,
summary: `删除车辆「${v.name}」(${v.plate || '无车牌'}`,
detail: { vehicle: v },
});
await db().run('UPDATE vehicles SET is_deleted = 1, updated_at = NOW() WHERE id = ?', [v.id]);
ok(res, { id: v.id, deleted: true });
});
// GET /api/vehicles/:id/health — 车辆健康仪表盘聚合数据
router.get('/vehicles/:id/health', async (req, res) => {
const id = req.params.id;
const v = await db().get('SELECT * FROM vehicles WHERE id = ? AND is_deleted = 0', [id]);
if (!v) return fail(res, 404, 'NOT_FOUND', '车辆不存在');
// 累计
const totals = await db().get(
`
SELECT
(SELECT COUNT(*) FROM wash_records WHERE vehicle_id = ? AND is_deleted = 0) AS wash_count,
(SELECT COALESCE(SUM(cost), 0) FROM wash_records WHERE vehicle_id = ? AND is_deleted = 0) AS wash_cost,
(SELECT COUNT(*) FROM refuel_records WHERE vehicle_id = ? AND is_deleted = 0) AS refuel_count,
(SELECT COALESCE(SUM(total_cost), 0) FROM refuel_records WHERE vehicle_id = ? AND is_deleted = 0) AS refuel_cost,
(SELECT COALESCE(SUM(liters), 0) FROM refuel_records WHERE vehicle_id = ? AND is_deleted = 0) AS refuel_liters,
(SELECT COUNT(*) FROM charging_records WHERE vehicle_id = ? AND is_deleted = 0) AS charge_count,
(SELECT COALESCE(SUM(total_cost), 0) FROM charging_records WHERE vehicle_id = ? AND is_deleted = 0) AS charge_cost,
(SELECT COALESCE(SUM(kwh), 0) FROM charging_records WHERE vehicle_id = ? AND is_deleted = 0) AS charge_kwh,
(SELECT COUNT(*) FROM maintenance_records WHERE vehicle_id = ? AND is_deleted = 0) AS maint_count,
(SELECT COALESCE(SUM(total_cost), 0) FROM maintenance_records WHERE vehicle_id = ? AND is_deleted = 0) AS maint_cost
`,
[id, id, id, id, id, id, id, id, id, id]
);
// 平均油耗(按 is_full 加满 + 里程计算)
// 算法:相邻两次加满的里程差 / 升数 × 100
const fullRefuels = await db().all(
`
SELECT id, refuel_date, liters, odometer_km
FROM refuel_records WHERE vehicle_id = ? AND is_deleted = 0 AND is_full = 1 AND odometer_km IS NOT NULL
ORDER BY refuel_date ASC, id ASC
`,
[id]
);
let refuelValues = [];
for (let i = 1; i < fullRefuels.length; i++) {
const cur = fullRefuels[i],
prev = fullRefuels[i - 1];
const dist = cur.odometer_km - prev.odometer_km;
if (dist > 0 && cur.liters > 0) {
refuelValues.push((cur.liters / dist) * 100);
}
}
const avgLPer100km = refuelValues.length ? refuelValues.reduce((a, b) => a + b, 0) / refuelValues.length : null;
// 平均电耗:相邻两次充电的度数 / 里程差 × 100
const charges = await db().all(
`
SELECT id, charge_date, kwh, odometer_km
FROM charging_records WHERE vehicle_id = ? AND is_deleted = 0 AND odometer_km IS NOT NULL
ORDER BY charge_date ASC, id ASC
`,
[id]
);
let chargeValues = [];
for (let i = 1; i < charges.length; i++) {
const cur = charges[i],
prev = charges[i - 1];
const dist = cur.odometer_km - prev.odometer_km;
if (dist > 0 && cur.kwh > 0) {
chargeValues.push((cur.kwh / dist) * 100);
}
}
const avgKwhPer100km = chargeValues.length ? chargeValues.reduce((a, b) => a + b, 0) / chargeValues.length : null;
// 最近 6 个月月度趋势
const monthly = await db().all(
`
SELECT DATE_FORMAT(month_start, '%Y-%m') AS month,
SUM(wash_cost) AS wash,
SUM(refuel_cost) AS refuel,
SUM(charge_cost) AS charge,
SUM(maint_cost) AS maint,
SUM(wash_cost + refuel_cost + charge_cost + maint_cost) AS total
FROM (
SELECT DATE_FORMAT(wash_date, '%Y-%m-01') AS month_start,
cost AS wash_cost, 0 AS refuel_cost, 0 AS charge_cost, 0 AS maint_cost
FROM wash_records WHERE vehicle_id = ? AND is_deleted = 0 AND wash_date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
UNION ALL
SELECT DATE_FORMAT(refuel_date, '%Y-%m-01'), 0, total_cost, 0, 0
FROM refuel_records WHERE vehicle_id = ? AND is_deleted = 0 AND refuel_date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
UNION ALL
SELECT DATE_FORMAT(charge_date, '%Y-%m-01'), 0, 0, total_cost, 0
FROM charging_records WHERE vehicle_id = ? AND is_deleted = 0 AND charge_date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
UNION ALL
SELECT DATE_FORMAT(maint_date, '%Y-%m-01'), 0, 0, 0, total_cost
FROM maintenance_records WHERE vehicle_id = ? AND is_deleted = 0 AND maint_date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
) t
GROUP BY month
ORDER BY month
`,
[id, id, id, id]
);
// 保养预测:最近一次保养 + 下次里程
const lastMaint = await db().get(
`
SELECT id, maint_date, odometer_km, next_due_km, next_due_date, shop, items_json, total_cost
FROM maintenance_records WHERE vehicle_id = ? AND is_deleted = 0
ORDER BY maint_date DESC, id DESC LIMIT 1
`,
[id]
);
// 里程:取所有记录最大里程
const maxOdo = await db().get(
`
SELECT GREATEST(
COALESCE((SELECT MAX(odometer_km) FROM refuel_records WHERE vehicle_id = ? AND is_deleted = 0), 0),
COALESCE((SELECT MAX(odometer_km) FROM charging_records WHERE vehicle_id = ? AND is_deleted = 0), 0),
COALESCE((SELECT MAX(odometer_km) FROM maintenance_records WHERE vehicle_id = ? AND is_deleted = 0), 0)
) AS current_km
`,
[id, id, id]
);
let nextMaint = null;
if (lastMaint && lastMaint.next_due_km) {
const remain = lastMaint.next_due_km - (maxOdo.current_km || 0);
nextMaint = {
last_date: lastMaint.maint_date,
last_odometer_km: lastMaint.odometer_km,
next_due_km: lastMaint.next_due_km,
km_remaining: remain,
km_remaining_pct: lastMaint.next_due_km
? Math.max(0, Math.min(100, (remain / lastMaint.next_due_km) * 100))
: null,
urgent: remain <= 0,
};
}
// 最近一次洗车距今
const lastWash = await db().get(
`SELECT wash_date FROM wash_records WHERE vehicle_id = ? AND is_deleted = 0 ORDER BY wash_date DESC LIMIT 1`,
[id]
);
let washRecency = null;
if (lastWash) {
const days = Math.floor((Date.now() - new Date(lastWash.wash_date).getTime()) / 86400000);
washRecency = { last_date: lastWash.wash_date, days_since: days, overdue: days > 14 };
}
// 累计总成本
const grandTotal =
(totals.wash_cost || 0) + (totals.refuel_cost || 0) + (totals.charge_cost || 0) + (totals.maint_cost || 0);
ok(res, {
vehicle: v,
totals: { ...totals, grand: grandTotal },
avg_consumption: {
l_per_100km: avgLPer100km ? Number(avgLPer100km.toFixed(2)) : null,
kwh_per_100km: avgKwhPer100km ? Number(avgKwhPer100km.toFixed(2)) : null,
refuel_samples: refuelValues.length,
charge_samples: chargeValues.length,
},
monthly,
last_maintenance: lastMaint,
next_maintenance: nextMaint,
current_km: maxOdo.current_km || 0,
wash_recency: washRecency,
});
});
// GET /api/vehicles/:id/last_odo — 加油/充电/保养/洗车 各自最后一次的里程 + 时间
// 给前端表单做"上次+差值"提示:用户填新里程时可以看到上次的数。
router.get('/vehicles/:id/last_odo', async (req, res) => {
const vid = Number(req.params.id);
if (!vid) return fail(res, 400, 'BAD_ID', '车辆 id 无效');
const v = await db().get('SELECT id, current_km FROM vehicles WHERE id = ?', [vid]);
if (!v) return fail(res, 404, 'NOT_FOUND', '车辆不存在');
const [refuel, charge, maint, wash] = await Promise.all([
db().get('SELECT refuel_date, odometer_km FROM refuel_records WHERE vehicle_id = ? AND is_deleted = 0 AND odometer_km > 0 ORDER BY refuel_date DESC, id DESC LIMIT 1', [vid]),
db().get('SELECT charge_date, odometer_km FROM charging_records WHERE vehicle_id = ? AND is_deleted = 0 AND odometer_km > 0 ORDER BY charge_date DESC, id DESC LIMIT 1', [vid]),
db().get('SELECT maint_date, odometer_km FROM maintenance_records WHERE vehicle_id = ? AND is_deleted = 0 AND odometer_km > 0 ORDER BY maint_date DESC, id DESC LIMIT 1', [vid]),
db().get('SELECT wash_date FROM wash_records WHERE vehicle_id = ? AND is_deleted = 0 ORDER BY wash_date DESC, id DESC LIMIT 1', [vid]),
]);
// 真实当前里程:取 last seen odo 和 current_km 中较大的那个
// wash_records 没有里程字段,仅给个时间参考,不参与 lastSeenOdo
const lastSeenOdo = Math.max(
refuel?.odometer_km || 0,
charge?.odometer_km || 0,
maint?.odometer_km || 0,
v.current_km || 0
);
ok(res, {
vehicle_id: vid,
manual_current_km: v.current_km || 0,
last_seen_km: lastSeenOdo,
refuel: refuel || null,
charge: charge || null,
maintenance: maint || null,
wash: wash || null,
});
});
export default router;
+426
View File
@@ -0,0 +1,426 @@
// server/src/routes/washes.js — 洗车记录 CRUD(含 Grocy 扣减 + 对比照)
import { Router } from 'express';
import multer from 'multer';
import path from 'node:path';
import fs from 'node:fs';
import url from 'node:url';
import { db } from '../db.js';
import { consumeGrocyStock } from '../services/grocyWrite.js';
import { grocyGet } from '../services/grocyClient.js';
import { loadConfig } from '../config.js';
import { logOperation } from '../services/operationLog.js';
import { verifyChallenge } from '../services/challenge.js';
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const PHOTOS_DIR = path.join(__dirname, '../../../uploads/washes');
fs.mkdirSync(PHOTOS_DIR, { recursive: true });
const photoStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, PHOTOS_DIR),
filename: (req, file, cb) => {
const ts = Date.now(),
rand = Math.random().toString(36).slice(2, 8);
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
cb(null, `wash-${ts}-${rand}${ext}`);
},
});
const photoUpload = multer({
storage: photoStorage,
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
fileFilter: (req, file, cb) => {
const ok = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/heic', 'image/heif'].includes(
file.mimetype
);
cb(null, ok);
},
});
const router = Router();
const TYPES = ['quick', 'full', 'detail', 'other'];
const LABEL = { quick: '快速', full: '标准', detail: '精洗', other: '其他' };
function ok(res, data) {
res.json(data);
}
function fail(res, status, code, message, extra) {
res.status(status).json({ ok: false, error: { code, message, ...(extra || {}) } });
}
function today() {
return new Date().toISOString().slice(0, 10);
}
function isoDaysAgo(d) {
return new Date(Date.now() - d * 86400 * 1000).toISOString().slice(0, 10);
}
// GET /api/washes?from=&to=&type=&vehicle_id=&page=&limit=
/**
* @openapi
* /api/washes:
* get:
* tags: [washes]
* summary: 列出洗车记录(分页 + 过滤)
* parameters:
* - in: query
* name: from
* schema: { type: string, format: date }
* description: 起日期 YYYY-MM-DD
* - in: query
* name: to
* schema: { type: string, format: date }
* - in: query
* name: type
* schema: { type: string, enum: [quick, full, detail, other] }
* - in: query
* name: vehicle_id
* schema: { type: integer }
* - in: query
* name: page
* schema: { type: integer, default: 1 }
* - in: query
* name: limit
* schema: { type: integer, default: 50 }
* responses:
* 200: { description: OK }
*/
router.get('/washes', async (req, res) => {
const from = req.query.from || isoDaysAgo(90);
const to = req.query.to || today();
const type = req.query.type || null;
const vehicleId = req.query.vehicle_id || null;
const page = Math.max(1, parseInt(req.query.page || '1'));
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit || '20')));
const offset = (page - 1) * limit;
let sql = `SELECT w.id, w.wash_date, w.wash_type, w.cost, w.location, w.notes, w.vehicle_id,
w.duration_min, w.created_at, w.updated_at,
ws.weather_desc, ws.weather_code, ws.temp_c, ws.humidity, ws.city,
v.name AS vehicle_name, v.plate AS vehicle_plate, v.type AS vehicle_type
FROM wash_records w
LEFT JOIN weather_snapshots ws ON ws.id = w.weather_snapshot_id
LEFT JOIN vehicles v ON v.id = w.vehicle_id
WHERE w.wash_date BETWEEN ? AND ? AND w.is_deleted = 0`;
const params = [from, to];
if (type) {
sql += ' AND w.wash_type = ?';
params.push(type);
}
if (vehicleId) {
sql += ' AND w.vehicle_id = ?';
params.push(vehicleId);
}
sql += ' ORDER BY w.wash_date DESC, w.id DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const rows = await db().all(sql, params);
let countSql = 'SELECT COUNT(*) AS c FROM wash_records WHERE wash_date BETWEEN ? AND ? AND is_deleted = 0';
const countParams = [from, to];
if (type) {
countSql += ' AND wash_type = ?';
countParams.push(type);
}
if (vehicleId) {
countSql += ' AND vehicle_id = ?';
countParams.push(vehicleId);
}
const total = (await db().get(countSql, countParams))?.c || 0;
ok(res, { rows, total, page, limit, total_pages: Math.ceil(total / limit), from, to });
});
// GET /api/washes/types
router.get('/washes/types', async (req, res) => {
ok(
res,
TYPES.map((v) => ({ value: v, label: LABEL[v] }))
);
});
// GET /api/washes/:id
router.get('/washes/:id', async (req, res) => {
const row = await db().get(
`SELECT w.*, ws.weather_desc, ws.temp_c, ws.humidity, ws.weather_code, ws.city AS weather_city,
v.name AS vehicle_name, v.plate AS vehicle_plate, v.type AS vehicle_type
FROM wash_records w
LEFT JOIN weather_snapshots ws ON ws.id = w.weather_snapshot_id
LEFT JOIN vehicles v ON v.id = w.vehicle_id
WHERE w.id = ?`,
[req.params.id]
);
if (!row) return fail(res, 404, 'NOT_FOUND', '记录不存在');
const chemicals = await db().all(
`SELECT cu.id, cu.chemical_id, cu.amount, cu.usage_date, cu.notes, cu.sync_status, cu.sync_at,
cu.created_at, c.name AS chemical_name, c.unit, c.category
FROM chemical_usage cu
LEFT JOIN chemicals c ON c.grocy_product_id = cu.chemical_id
WHERE cu.wash_record_id = ?
ORDER BY cu.id DESC`,
[req.params.id]
);
ok(res, { ...row, chemicals });
});
// POST /api/washes
router.post('/washes', async (req, res) => {
const b = req.body || {};
const errors = {};
if (!b.wash_date || !/^\d{4}-\d{2}-\d{2}$/.test(b.wash_date)) errors.wash_date = '必填(YYYY-MM-DD';
else if (b.wash_date > today()) errors.wash_date = '不能晚于今天';
if (!TYPES.includes(b.wash_type)) errors.wash_type = `必填(${TYPES.join('/')}`;
if (b.cost == null || b.cost === '' || isNaN(b.cost) || Number(b.cost) < 0) errors.cost = '必填且 ≥ 0';
if (
b.duration_min != null &&
b.duration_min !== '' &&
(isNaN(b.duration_min) || Number(b.duration_min) < 1 || Number(b.duration_min) > 1440)
)
errors.duration_min = '11440';
if (b.vehicle_id) {
const v = await db().get('SELECT is_active FROM vehicles WHERE id = ? AND is_deleted = 0', [b.vehicle_id]);
if (!v || !v.is_active) errors.vehicle_id = '车辆不存在或已停用';
}
if (Object.keys(errors).length) return fail(res, 422, 'VALIDATION', '校验失败', { errors });
const info = await db().run(
`INSERT INTO wash_records (wash_date, wash_type, vehicle_id, location, cost, duration_min, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
b.wash_date,
b.wash_type,
b.vehicle_id || null,
b.location || null,
Number(b.cost),
b.duration_min ? Number(b.duration_min) : null,
b.notes || null,
]
);
const washId = Number(info.lastInsertRowid);
// 保存 chemicals + 异步同步到 Grocy
if (Array.isArray(b.chemicals) && b.chemicals.length) {
const usageIds = [];
for (const c of b.chemicals) {
if (!c.chemical_id || !c.amount) continue;
const chem = await db().get(
'SELECT qu_factor, qu_id, consume_unit_id, unit FROM chemicals WHERE grocy_product_id = ?',
[c.chemical_id]
);
const quFactor = chem ? Number(chem.qu_factor || 1) : 1;
const inputAmount = Number(c.amount);
const stockAmount = Math.round(inputAmount * quFactor * 1000) / 1000;
const r = await db().run(
`INSERT INTO chemical_usage (usage_date, chemical_id, amount, unit, stock_amount, consume_unit_id, wash_record_id, notes, sync_status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', NOW(), NOW())`,
[
b.wash_date,
c.chemical_id,
inputAmount,
c.unit || chem?.unit || null,
stockAmount,
chem?.consume_unit_id || null,
washId,
c.notes || null,
]
);
usageIds.push({ id: Number(r.lastInsertRowid), chemical_id: c.chemical_id, stock_amount: stockAmount });
}
if (usageIds.length) syncChemicalsToGrocyInBackground(usageIds, b.wash_date, washId);
}
ok(res, { id: washId });
});
/**
* 后台把 chemical_usage 同步到 Grocy 扣减库存
*/
function syncChemicalsToGrocyInBackground(usageIds, washDate, washId) {
setImmediate(async () => {
const cfg = await loadConfig();
if (!cfg.grocy.url) {
if (usageIds[0])
await db().run("UPDATE chemical_usage SET sync_status = 'failed', updated_at = NOW() WHERE id = ?", [
usageIds[0].id,
]);
return;
}
for (const u of usageIds) {
try {
const chem = await db().get('SELECT source, name FROM chemicals WHERE grocy_product_id = ?', [
u.chemical_id,
]);
if (!chem || chem.source !== 'grocy') {
await db().run(
"UPDATE chemical_usage SET sync_status = 'skipped', updated_at = NOW() WHERE id = ?",
[u.id]
);
continue;
}
await consumeGrocyStock(cfg, u.chemical_id, {
amount: u.stock_amount,
transaction_type: 'consume',
note: `洗车记录 #${washId} (${washDate})`,
});
await db().run(
"UPDATE chemical_usage SET sync_status = 'synced', sync_at = NOW(), updated_at = NOW() WHERE id = ?",
[u.id]
);
} catch (e) {
await db().run("UPDATE chemical_usage SET sync_status = 'failed', updated_at = NOW() WHERE id = ?", [
u.id,
]);
console.error(`[grocy consume] failed for ${u.chemical_id}: ${e.message}`);
}
}
try {
const { pullProducts } = await import('../services/grocyProducts.js');
await pullProducts(cfg);
} catch {}
});
}
// 取一批 id 存在的 wash 记录(用于删除前快照)
async function fetchForDelete(ids) {
if (!ids.length) return [];
const placeholders = ids.map(() => '?').join(',');
return await db().all(
`SELECT w.id, w.wash_date, w.wash_type, w.cost, w.location, w.notes, w.vehicle_id,
w.duration_min, w.created_at, v.name AS vehicle_name, v.plate AS vehicle_plate
FROM wash_records w
LEFT JOIN vehicles v ON v.id = w.vehicle_id
WHERE w.id IN (${placeholders}) AND w.is_deleted = 0`,
ids
);
}
// DELETE /api/washes/:id —— 软删(is_deleted=1
router.delete('/washes/:id', async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return fail(res, 400, 'BAD_ID', 'id 非法');
const snapshots = await fetchForDelete([id]);
if (!snapshots.length) return fail(res, 404, 'NOT_FOUND', '记录不存在');
await db().run('UPDATE chemical_usage SET is_deleted = 1 WHERE wash_record_id = ?', [id]);
await db().run('UPDATE wash_records SET is_deleted = 1 WHERE id = ?', [id]);
const s = snapshots[0];
logOperation({
req,
action: 'delete',
targetType: 'wash_record',
targetIds: [id],
summary:
`删除洗车 ${s.wash_date} ${LABEL[s.wash_type] || s.wash_type} ¥${Number(s.cost).toFixed(2)}` +
(s.vehicle_name ? ` / ${s.vehicle_name}` : ''),
detail: { snapshot: s },
});
ok(res, { deleted: 1 });
});
// POST /api/washes/batch-delete —— 批量软删(is_deleted=1
router.post('/washes/batch-delete', async (req, res) => {
const b = req.body || {};
const ids = Array.isArray(b.ids) ? b.ids.map(Number).filter(Number.isInteger) : [];
if (!ids.length) return fail(res, 400, 'NO_IDS', 'ids 必填且非空');
if (ids.length > 500) return fail(res, 400, 'TOO_MANY', '单次最多 500 条');
// 二次确认:计算题校验(防误删/防脚本)
const ok = verifyChallenge(b.challenge || {});
if (!ok) return fail(res, 422, 'CONFIRM_FAIL', '二次确认校验失败,请重做计算题');
const snapshots = await fetchForDelete(ids);
if (!snapshots.length) return fail(res, 404, 'NOT_FOUND', '记录不存在');
const placeholders = ids.map(() => '?').join(',');
await db().run(`UPDATE chemical_usage SET is_deleted = 1 WHERE wash_record_id IN (${placeholders})`, ids);
await db().run(`UPDATE wash_records SET is_deleted = 1 WHERE id IN (${placeholders})`, ids);
logOperation({
req,
action: 'batch_delete',
targetType: 'wash_record',
targetIds: snapshots.map((s) => s.id),
summary: `批量删除 ${snapshots.length} 条洗车记录(合计 ¥${snapshots.reduce((s, x) => s + Number(x.cost || 0), 0).toFixed(2)}`,
detail: { snapshots, challenge: c },
});
ok(res, { deleted: snapshots.length });
});
// ====== 洗车对比照 ======
const PHOTO_TYPES = new Set(['before', 'after', 'detail', 'scene']);
// POST /api/washes/:id/photos — 上传一张照片
router.post('/washes/:id/photos', photoUpload.single('file'), async (req, res) => {
try {
if (!req.file) return fail(res, 422, 'BAD_IMAGE', '请上传图片(jpg/png/webp/heic),最大 15MB');
const wid = Number(req.params.id);
const wash = await db().get('SELECT id FROM wash_records WHERE id = ? AND is_deleted = 0', [wid]);
if (!wash) return fail(res, 404, 'NOT_FOUND', '洗车记录不存在');
const photoType = PHOTO_TYPES.has(req.body.photo_type) ? req.body.photo_type : 'detail';
const relPath = path.relative(path.join(__dirname, '../../..'), req.file.path).replace(/\\/g, '/');
const r = await db().run(
`INSERT INTO wash_photos (wash_id, photo_type, file_path, file_name, mime_type, file_size, caption, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())`,
[
wid,
photoType,
relPath,
req.file.originalname,
req.file.mimetype,
req.file.size,
req.body.caption || null,
Number(req.body.sort_order || 0),
]
);
ok(res, {
id: Number(r.lastInsertRowid),
url: `/api/${relPath}`,
photo_type: photoType,
file_name: req.file.originalname,
});
} catch (e) {
fail(res, 500, 'UPLOAD_FAIL', e.message);
}
});
// GET /api/washes/:id/photos — 列出某条洗车的所有照片
router.get('/washes/:id/photos', async (req, res) => {
const rows = await db().all(
`SELECT id, photo_type, file_path, file_name, mime_type, file_size, caption, sort_order, created_at
FROM wash_photos WHERE wash_id = ? AND is_deleted = 0 ORDER BY photo_type, sort_order, id`,
[req.params.id]
);
// 加 url 字段
for (const r of rows) r.url = `/api/${r.file_path}`;
ok(res, rows);
});
// DELETE /api/washes/:id/photos/:photoId — 软删一张
router.delete('/washes/:id/photos/:photoId', async (req, res) => {
const r = await db().run(`UPDATE wash_photos SET is_deleted = 1 WHERE id = ? AND wash_id = ?`, [
req.params.photoId,
req.params.id,
]);
if (!r.changes) return fail(res, 404, 'NOT_FOUND', '照片不存在');
ok(res, { id: Number(req.params.photoId), deleted: true });
});
// GET /api/washes/:id/photos/compare?type1=before&type2=after — 拿一张照片做对比(前后对照)
router.get('/washes/:id/photos/compare', async (req, res) => {
const type1 = req.query.type1 || 'before';
const type2 = req.query.type2 || 'after';
const [b, a] = await Promise.all([
db().get(
`SELECT * FROM wash_photos WHERE wash_id = ? AND photo_type = ? AND is_deleted = 0 ORDER BY sort_order, id LIMIT 1`,
[req.params.id, type1]
),
db().get(
`SELECT * FROM wash_photos WHERE wash_id = ? AND photo_type = ? AND is_deleted = 0 ORDER BY sort_order, id LIMIT 1`,
[req.params.id, type2]
),
]);
ok(res, {
before: b ? { ...b, url: `/api/${b.file_path}` } : null,
after: a ? { ...a, url: `/api/${a.file_path}` } : null,
});
});
export default router;
+226
View File
@@ -0,0 +1,226 @@
// server/src/services/aiVision.js — 通用 OpenAI 兼容多模态识别
// 用户在 Settings 里配 ai_provider_url / ai_api_key / ai_model
// 默认指向 https://api.openai.com/v1,也支持国产(智谱 / DeepSeek / Moonshot 等)
import fs from 'node:fs';
import path from 'node:path';
import { db } from '../db.js';
const SCHEMAS = {
wash: {
name: '洗车记录',
schema: {
wash_date: 'string YYYY-MM-DD(消费当天日期)',
wash_type: 'enum: quick / full / detail / other(快速洗/标准洗/精洗/其他)',
cost: 'number ¥(消费金额)',
location: 'string 商家/门店名(可选)',
vehicle_hint: 'string 涉及的车型/品牌线索(可选)',
notes: 'string 备注(可选)',
},
},
refuel: {
name: '加油小票',
schema: {
refuel_date: 'string YYYY-MM-DD(加油日期)',
liters: 'number 升数 L',
price_per_liter: 'number 单价 ¥/L',
total_cost: 'number 总价 ¥',
fuel_type: 'enum: 92# / 95# / 98# / 0#柴油 / -10#柴油 / E92乙醇 / E95乙醇 / LPG',
is_full: 'number 1 或 0(是否加满)',
station: 'string 加油站名(可选)',
odometer_km: 'number 里程表 km(截图有就填,可选)',
},
},
charge: {
name: '充电订单',
schema: {
charge_date: 'string YYYY-MM-DD(充电日期)',
kwh: 'number 度数 kWh',
price_per_kwh: 'number 单价 ¥/kWh',
total_cost: 'number 总价 ¥',
charge_type: 'enum: home / slow / fast / public(家充/慢充/快充/公共桩)',
start_soc: 'number 起始电量 %(可选)',
end_soc: 'number 结束电量 %(可选)',
station: 'string 充电站名(可选)',
},
},
maint: {
name: '保养小票',
schema: {
maint_date: 'string YYYY-MM-DD',
total_cost: 'number ¥ 保养总费用',
shop: 'string 维修店/4S店名(可选)',
odometer_km: 'number 里程表 km(可选)',
items: 'array of {name: string, cost: number}(保养项目,每个含名称和费用)',
next_due_km: 'number 下次保养里程(可选)',
},
},
insurance: {
name: '保单',
schema: {
insurance_type: 'enum: 交强险 / 商业险 / 车损险 / 三责险 / 座位险 / 不计免赔 / 玻璃险 / 划痕险 / 自燃险 / 涉水险',
company: 'string 保险公司',
policy_no: 'string 保单号',
start_date: 'string YYYY-MM-DD 生效日',
end_date: 'string YYYY-MM-DD 到期日',
premium: 'number ¥ 保费',
coverage_amount: 'number ¥ 保额(可选)',
},
},
};
export const TYPES = Object.keys(SCHEMAS);
/**
* 读取 AI 配置
* 支持的 provider
* - openai_compat(默认):OpenAI 兼容端点(OpenAI / 月之暗面 Kimi / 硅基流动 / DeepSeek 等)
* - minimax_vlMiniMax M3 多模态(OpenAI 兼容协议,model="MiniMax-M3"
*
* 注意:MiniMax M3 是原生多模态,直接走 /chat/completions 即可,
* 跟 OpenAI 协议完全一致,只是 base_url 和 model 不同。
*/
export async function getAiConfig() {
const rows = await db().all('SELECT `key`, value FROM settings');
const cfg = {};
for (const r of rows) cfg[r.key] = r.value;
const provider = cfg.ai_provider || 'openai_compat';
let defaultUrl, defaultModel;
if (provider === 'minimax_vl') {
// MiniMax 开放平台(OpenAI 兼容协议 /chat/completions
// Token Plan 订阅 key 与按量 key 都可使用
defaultUrl = 'https://api.minimaxi.com/v1';
defaultModel = 'MiniMax-M3';
} else {
defaultUrl = 'https://api.openai.com/v1';
defaultModel = 'gpt-4o-mini';
}
return {
provider,
provider_url: cfg.ai_provider_url || defaultUrl,
api_key: cfg.ai_api_key || '',
model: cfg.ai_model || defaultModel,
enabled: cfg.ai_enabled === '1',
};
}
/**
* 端点:所有 provider 都用标准 /chat/completionsMiniMax M3 原生多模态走这个端点)
*/
function endpointFor(cfg) {
const base = cfg.provider_url.replace(/\/+$/, '');
return base + '/chat/completions';
}
function buildPrompt(type) {
const meta = SCHEMAS[type];
if (!meta) throw new Error(`未知识别类型:${type}`);
const fields = Object.entries(meta.schema)
.map(([k, v]) => ` "${k}": ${v}`)
.join(',\n');
return `你是一个「${meta.name}」信息提取助手。仔细看图,按以下 JSON schema 提取字段(**只输出 JSON,不要任何解释文字、Markdown 代码块、emoji**):
{
${fields}
}
要求:
1. 数字字段只输出 number 类型,不要加 ¥ / 元 / km / L 等单位
2. 日期统一 YYYY-MM-DD
3. 字段填不出来的就 null,不要瞎猜
4. 加油小票的 fuel_type 必须是 schema 里的枚举值之一
5. 充电订单的 charge_type 同理
6. 保养 items 数组尽量拆细,每条是一个 {name, cost}
7. 不要任何 markdown 包装(不要 \`\`\`json 标记),输出纯 JSON`;
}
/**
* 把图片转成 base64 data URL
*/
function imageToDataUrl(filePath) {
const buf = fs.readFileSync(filePath);
const ext = path.extname(filePath).slice(1).toLowerCase();
const mime = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`;
return `data:${mime};base64,${buf.toString('base64')}`;
}
/**
* 调 OpenAI 兼容 /chat/completions 识别图片
* @param {string} imagePath 本地图片路径
* @param {string} type wash / refuel / charge / maint / insurance
* @returns {Promise<{data: object, raw: string, usage: object}>}
*/
export async function recognizeImage(imagePath, type) {
const cfg = await getAiConfig();
if (!cfg.api_key) throw new Error('未配置 AI API key(设置 → AI 截图识别)');
if (!cfg.enabled) throw new Error('AI 识别未启用(设置 → AI 截图识别)');
const dataUrl = imageToDataUrl(imagePath);
const prompt = buildPrompt(type);
const body = {
model: cfg.model,
messages: [{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: dataUrl } },
{ type: 'text', text: prompt },
],
}],
temperature: 0.1,
max_tokens: 800,
};
// MiniMax M3 默认开启 thinking,会污染 JSON 解析;OCR 任务关掉
if (cfg.provider === 'minimax_vl') {
body.thinking = { type: 'disabled' };
}
const url = endpointFor(cfg);
const r = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${cfg.api_key}`,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(60_000),
});
if (!r.ok) {
const errText = await r.text();
throw new Error(`AI API 返 ${r.status}: ${errText.slice(0, 200)}`);
}
const j = await r.json();
const content = j.choices?.[0]?.message?.content || '';
// 提取 JSON(容错处理 markdown 包装)
const jsonText = extractJson(content);
let data = null;
try {
data = JSON.parse(jsonText);
} catch (e) {
throw new Error(`AI 返的不是合法 JSON: ${content.slice(0, 200)}`);
}
return {
data,
raw: content,
usage: j.usage || {},
model: j.model || cfg.model,
};
}
function extractJson(text) {
const trimmed = text.trim();
// 1. 直接就是 JSON
if (trimmed.startsWith('{') || trimmed.startsWith('[')) return trimmed;
// 2. markdown ```json ... ``` 包裹
const m = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
if (m) return m[1].trim();
// 3. 找第一个 { 到最后一个 }
const a = trimmed.indexOf('{');
const b = trimmed.lastIndexOf('}');
if (a >= 0 && b > a) return trimmed.slice(a, b + 1);
return trimmed;
}
+112
View File
@@ -0,0 +1,112 @@
// server/src/services/auth.js — 用户管理 + session + CSRF
import bcrypt from 'bcryptjs';
import crypto from 'node:crypto';
import { db } from '../db.js';
const HASH_PREFIX = '$2'; // bcrypt
export async function userExists(username) {
const r = await db().get('SELECT 1 FROM users WHERE username = ? LIMIT 1', [username]);
return !!r;
}
export async function findUser(username) {
const u = await db().get('SELECT * FROM users WHERE username = ? LIMIT 1', [username]);
return u || null;
}
export async function findUserById(id) {
const u = await db().get('SELECT id, username, is_active, last_login_at, last_login_ip, created_at FROM users WHERE id = ?', [id]);
return u || null;
}
export async function createUser(username, password, bcryptCost = 12) {
const hash = bcrypt.hashSync(password, bcryptCost);
const info = await db().run('INSERT INTO users (username, password_hash) VALUES (?, ?)', [username, hash]);
return Number(info.lastInsertRowid);
}
export async function changePassword(userId, newPassword, bcryptCost = 12) {
const hash = bcrypt.hashSync(newPassword, bcryptCost);
await db().run('UPDATE users SET password_hash = ?, updated_at = NOW() WHERE id = ?', [hash, userId]);
}
export async function changeUsername(userId, newUsername) {
if (!newUsername || newUsername.length > 64) throw new Error('用户名长度 1-64');
if (!/^[A-Za-z0-9_.\-@]+$/.test(newUsername)) throw new Error('只允许字母数字 _ . - @');
const exists = await db().get('SELECT id FROM users WHERE username = ? AND id != ?', [newUsername, userId]);
if (exists) throw new Error('该用户名已存在');
await db().run('UPDATE users SET username = ?, updated_at = NOW() WHERE id = ?', [newUsername, userId]);
}
export async function listUsers() {
return await db().all('SELECT id, username, is_active, last_login_at, last_login_ip, created_at FROM users ORDER BY id');
}
export async function verifyPassword(username, password) {
const u = await findUser(username);
if (!u) return null;
if (!u.password_hash.startsWith(HASH_PREFIX)) return null;
return bcrypt.compareSync(password, u.password_hash) ? u : null;
}
/** 纯函数:bcrypt hash 一个明文密码(不写库) */
export function hashPassword(plain, cost = 12) {
return bcrypt.hashSync(plain, cost);
}
/** 纯函数:bcrypt compare(不查库) */
export function compareHash(plain, hash) {
return bcrypt.compareSync(plain, hash);
}
export async function loginSuccess(userId, ip) {
await db().run('UPDATE users SET last_login_at = NOW(), last_login_ip = ? WHERE id = ?', [ip, userId]);
}
export async function deleteUser(id) {
await db().run('DELETE FROM users WHERE id = ?', [id]);
}
export async function setActive(id, active) {
await db().run('UPDATE users SET is_active = ?, updated_at = NOW() WHERE id = ?', [active ? 1 : 0, id]);
}
// ===== Session 工具(在 req.session 上读写)=====
export function isLoggedIn(req) {
return !!(req.session && req.session.userId);
}
// ===== CSRF 工具 =====
const CSRF_BITS = 128;
const CSRF_TTL_SECONDS = 3600 * 24; // 24h
export function csrfToken(req) {
if (!req.session) return null;
if (req.session.csrfToken) {
const [token, ts] = req.session.csrfToken.split('_');
if (Date.now() / 1000 - Number(ts) < CSRF_TTL_SECONDS) return req.session.csrfToken;
}
const token = crypto.randomBytes(CSRF_BITS / 8).toString('hex') + '_' + Math.floor(Date.now() / 1000);
req.session.csrfToken = token;
return token;
}
export function verifyCsrf(req, token) {
if (!token) return false;
const expected = csrfToken(req);
if (!expected) return false;
if (token !== expected) return false;
const [, ts] = expected.split('_');
return Date.now() / 1000 - Number(ts) < CSRF_TTL_SECONDS;
}
export function setSession(req, userId, username) {
req.session.userId = userId;
req.session.username = username;
if (req.session.csrfToken) delete req.session.csrfToken;
}
export function clearSession(req) {
req.session?.destroy?.();
}
+88
View File
@@ -0,0 +1,88 @@
// server/src/services/backup.js — tar.gz 备份
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { db } from '../db.js';
/**
* 备份 SQLite + exports 到 tar.gz
* @param {object} opts
* @param {string} [opts.out] 输出目录
* @param {number} [opts.keep] 保留份数
*/
export function runBackup(opts = {}) {
const out = opts.out || path.join(process.cwd(), 'storage', 'backups');
const keep = opts.keep || 10;
fs.mkdirSync(out, { recursive: true });
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 13); // YYYYMMDDHHMM
const tmpdir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'cw-bak-'));
const stampDir = path.join(tmpdir, `carwash-${stamp}`);
fs.mkdirSync(stampDir, { recursive: true });
// 1. checkpoint WAL → 主文件
try { db().pragma('wal_checkpoint(TRUNCATE)'); } catch {}
// 2. 拷贝 SQLite 文件
const dbFile = process.env.DB_PATH || findDefaultDbPath();
if (fs.existsSync(dbFile)) {
fs.copyFileSync(dbFile, path.join(stampDir, path.basename(dbFile)));
// WAL/SHM
for (const suf of ['-shm', '-wal']) {
const p = dbFile + suf;
if (fs.existsSync(p)) fs.copyFileSync(p, path.join(stampDir, path.basename(dbFile) + suf));
}
}
// 3. 拷贝 exports
const exportsDir = path.join(process.cwd(), 'storage', 'exports');
if (fs.existsSync(exportsDir)) {
const dest = path.join(stampDir, 'exports');
fs.mkdirSync(dest, { recursive: true });
for (const f of fs.readdirSync(exportsDir)) {
fs.copyFileSync(path.join(exportsDir, f), path.join(dest, f));
}
}
// 4. tar.gz
const archive = path.join(out, `carwash-${stamp}.tar.gz`);
const r = spawnSync('tar', ['czf', archive, '-C', tmpdir, `carwash-${stamp}`], { stdio: 'pipe' });
if (r.status !== 0) throw new Error('tar failed: ' + r.stderr.toString());
// 5. 清理临时
fs.rmSync(tmpdir, { recursive: true, force: true });
// 6. 清理老的(保留最新 N 份)
const files = fs.readdirSync(out).filter(f => f.startsWith('carwash-') && f.endsWith('.tar.gz')).sort().reverse();
for (let i = keep; i < files.length; i++) {
try { fs.unlinkSync(path.join(out, files[i])); } catch {}
}
return { archive, kept: Math.min(keep, files.length + 1) };
}
function findDefaultDbPath() {
return path.join(process.cwd(), 'server', 'data', 'carwash.sqlite');
}
export function cli(argv) {
if (argv.includes('--help') || argv.includes('-h')) {
console.log(`Usage: backup [--out=DIR] [--keep=10]
备份 SQLite + exports 到 tar.gz。默认 10 份轮转。`);
return;
}
const args = parseArgs(argv);
const r = runBackup({ out: args.out, keep: args.keep ? Number(args.keep) : 10 });
console.log(`✓ Backup: ${r.archive}`);
console.log(` Kept: ${r.kept} file(s) in ${args.out || 'storage/backups/'}`);
}
function parseArgs(argv) {
const a = {};
for (const x of argv) {
const m = x.match(/^--([^=]+)(?:=(.*))?$/);
if (m) a[m[1]] = m[2] ?? true;
}
return a;
}
+44
View File
@@ -0,0 +1,44 @@
// server/src/services/categoryMap.js — 解析 category_mappings 表 + settings.grocy_categories_json
import { db } from '../db.js';
let _cache = null;
let _cacheAt = 0;
const TTL = 60_000; // 1 分钟
/**
* 获取分类 ID → 显示名映射(合并 category_mappings 表 + settings.grocy_categories_json
* @returns {Promise<Object<number, string>>}
*/
export async function getCategoryMap() {
if (_cache && Date.now() - _cacheAt < TTL) return _cache;
const m = {};
const row = await db().get('SELECT value FROM settings WHERE `key` = ?', ['grocy_categories_json']);
if (row?.value) {
try {
const arr = JSON.parse(row.value);
for (const x of arr) if (x.id != null && x.name) m[Number(x.id)] = x.name;
} catch {}
}
const rows = await db().all('SELECT grocy_group_id, display_name FROM category_mappings');
for (const r of rows) m[r.grocy_group_id] = r.display_name;
_cache = m;
_cacheAt = Date.now();
return m;
}
export function invalidateCategoryMap() {
_cache = null;
_cacheAt = 0;
}
/**
* 把 grocy_group_id 转成显示名(拿不到名字就返回 group-N 兜底)
* @param {number|null|undefined} gid
* @returns {string}
*/
export function resolveCategory(gid) {
if (gid == null) return '未分类';
// 同步缓存版本(用于 enrich() 等同步上下文)
if (_cache) return _cache[Number(gid)] || `group-${gid}`;
return `group-${gid}`;
}
+29
View File
@@ -0,0 +1,29 @@
// server/src/services/challenge.js — 二次确认计算题(纯函数)
// 用途:批量删除等敏感操作要求前端做一道算术题,防止误操作/脚本。
// 流程:前端 GET 接口 → 服务端返回 { a, b, op } → 前端展示给用户填 answer →
// 用户提交 answer → 服务端用本函数校验 answer === a OP b。
//
// 为什么独立成纯函数:方便单测覆盖 + 各路由复用。
const OPS = {
'+': (a, b) => a + b,
'-': (a, b) => a - b,
'*': (a, b) => a * b,
};
/**
* 校验用户提交的算术题答案
* @param {{a:number,b:number,op:'+'|'-'|'*',answer:number|string}} challenge
* @returns {boolean} 正确返回 true
*/
export function verifyChallenge(challenge) {
if (!challenge || typeof challenge !== 'object') return false;
const { a, b, op, answer } = challenge;
if (typeof op !== 'string' || !(op in OPS)) return false;
const aN = Number(a),
bN = Number(b);
if (!Number.isFinite(aN) || !Number.isFinite(bN)) return false;
const expected = OPS[op](aN, bN);
const got = Number(answer);
return Number.isFinite(got) && got === expected;
}
+95
View File
@@ -0,0 +1,95 @@
// server/src/services/exporter.js — CSV 导出
import fs from 'node:fs';
import path from 'node:path';
import { db } from '../db.js';
/**
* 导出 wash_records + chemical_usage 为 CSV
* @param {object} opts
* @param {string} [opts.from] YYYY-MM-DD
* @param {string} [opts.to]
* @param {string} [opts.out] 输出目录
* @param {string} [opts.type] 'wash'|'chemical'|'both'
* @returns {Promise<{files:string[]}>}
*/
export async function exportCsv(opts = {}) {
const from = opts.from || isoDaysAgo(90);
const to = opts.to || today();
const out = opts.out || path.join(process.cwd(), 'storage', 'exports');
const type = opts.type || 'both';
fs.mkdirSync(out, { recursive: true });
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const files = [];
if (type === 'wash' || type === 'both') {
const rows = await db().all(
`SELECT w.id, w.wash_date, w.wash_type, w.location, w.cost, w.duration_min, w.notes,
w.created_at, w.updated_at,
ws.weather_desc, ws.temp_c, ws.humidity, ws.city, ws.provider,
v.name AS vehicle_name, v.plate AS vehicle_plate
FROM wash_records w
LEFT JOIN weather_snapshots ws ON ws.id = w.weather_snapshot_id
LEFT JOIN vehicles v ON v.id = w.vehicle_id
WHERE w.wash_date BETWEEN ? AND ?
ORDER BY w.wash_date, w.id`, [from, to]);
const fp = path.join(out, `wash-${stamp}.csv`);
const csv = toCsv(rows, ['id','wash_date','wash_type','location','cost','duration_min','notes','created_at','updated_at','weather_desc','temp_c','humidity','city','provider','vehicle_name','vehicle_plate']);
fs.writeFileSync(fp, csv);
files.push(fp);
}
if (type === 'chemical' || type === 'both') {
const rows = await db().all(
`SELECT cu.id, cu.usage_date, cu.chemical_id AS grocy_product_id, cu.amount, cu.wash_record_id, cu.notes, cu.sync_status, cu.created_at,
c.name AS chemical_name, c.unit, c.category
FROM chemical_usage cu
LEFT JOIN chemicals c ON c.grocy_product_id = cu.chemical_id
WHERE cu.usage_date BETWEEN ? AND ?
ORDER BY cu.usage_date, cu.id`, [from, to]);
const fp = path.join(out, `chemical-${stamp}.csv`);
const csv = toCsv(rows, ['id','usage_date','grocy_product_id','chemical_name','unit','category','amount','wash_record_id','notes','sync_status','created_at']);
fs.writeFileSync(fp, csv);
files.push(fp);
}
return { files };
}
function toCsv(rows, headers) {
const out = [headers.join(',')];
for (const r of rows) {
out.push(headers.map(h => csvCell(r[h])).join(','));
}
return out.join('\n') + '\n';
}
function csvCell(v) {
if (v == null) return '';
const s = String(v);
if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
return s;
}
export async function cli(argv) {
if (argv.includes('--help') || argv.includes('-h')) {
console.log(`Usage: export [--from=YYYY-MM-DD] [--to=YYYY-MM-DD] [--type=wash|chemical|both] [--out=DIR]
默认导出最近 90 天到 storage/exports/。`);
return;
}
const args = parseArgs(argv);
const r = await exportCsv({ from: args.from, to: args.to, out: args.out, type: args.type });
console.log(`✓ Exported ${r.files.length} file(s):`);
for (const f of r.files) console.log(` ${f}`);
}
function parseArgs(argv) {
const a = {};
for (const x of argv) {
const m = x.match(/^--([^=]+)(?:=(.*))?$/);
if (m) a[m[1]] = m[2] ?? true;
}
return a;
}
function today() { return new Date().toISOString().slice(0, 10); }
function isoDaysAgo(d) { return new Date(Date.now() - d * 86400 * 1000).toISOString().slice(0, 10); }
+86
View File
@@ -0,0 +1,86 @@
// server/src/services/grocy.js — Grocy 库存扣减同步
import { httpGet, httpPost } from '../http.js';
import { db } from '../db.js';
/**
* 同步 chemical_usage 表到 Grocy 库存扣减
* @param {object} cfg
* @param {object} opts
* @param {string} [opts.since] YYYY-MM-DD,只同步此日期之后的
* @param {boolean} [opts.dryRun]
* @returns {Promise<{ok:number, fail:number, items:Array}>}
*/
export async function syncUsageToGrocy(cfg, opts = {}) {
if (!cfg.grocy.url) throw new Error('未配置 GROCY_URL');
if (!cfg.grocy.api_token) throw new Error('未配置 GROCY_API_TOKEN');
const since = opts.since || isoDaysAgo(7);
const rows = await db().all(
`SELECT cu.id, cu.usage_date, cu.chemical_id, cu.amount, c.name
FROM chemical_usage cu
LEFT JOIN chemicals c ON c.grocy_product_id = cu.chemical_id
WHERE cu.sync_status = 'pending' AND cu.usage_date >= ?
ORDER BY cu.usage_date, cu.id`, [since]);
// 按 product 聚合
const agg = {};
for (const r of rows) {
const k = r.chemical_id;
if (!agg[k]) agg[k] = { product_id: k, name: r.name, total: 0, ids: [] };
agg[k].total += r.amount;
agg[k].ids.push(r.id);
}
const results = [];
for (const a of Object.values(agg)) {
const url = `${cfg.grocy.url}/api/stock/products/${encodeURIComponent(a.product_id)}/consume`;
const amount = Math.round(a.total * 100) / 100; // 保留 2 位小数
if (opts.dryRun) {
results.push({ product_id: a.product_id, name: a.name, amount, status: 'dry-run' });
continue;
}
try {
await httpPost(url, { amount }, {
headers: { 'GROCY-API-TOKEN': cfg.grocy.api_token, 'Content-Type': 'application/json' },
timeout: 10000,
});
// 标记 synced
const placeholders = a.ids.map(() => '?').join(',');
(await db().run(`UPDATE chemical_usage SET sync_status = 'synced', sync_at = NOW(), updated_at = NOW() WHERE id IN (${placeholders})`, [...a.ids]));
results.push({ product_id: a.product_id, name: a.name, amount, status: 'ok' });
} catch (e) {
(await db().run(`UPDATE chemical_usage SET sync_status = 'failed', updated_at = NOW() WHERE id IN (${a.ids.map(() => '?').join(',')})`, [...a.ids]));
results.push({ product_id: a.product_id, name: a.name, amount, status: 'fail', error: e.message });
}
}
return { ok: results.filter(r => r.status === 'ok').length, fail: results.filter(r => r.status === 'fail').length, items: results };
}
export async function cli(argv, cfg) {
if (argv.includes('--help') || argv.includes('-h')) {
console.log(`Usage: grocy-sync [--since=YYYY-MM-DD] [--dry-run]
把 chemical_usage 表中 sync_status=pending 的记录聚合到 Grocy 库存扣减。`);
return;
}
const args = parseArgs(argv);
cfg = cfg || (await import('../config.js')).loadConfig();
if (!cfg.grocy.url || !cfg.grocy.api_token) {
console.log('✗ 未配置 GROCY_URL / GROCY_API_TOKEN(设置 → Grocy');
process.exit(78);
}
const r = await syncUsageToGrocy(cfg, { since: args.since, dryRun: args['dry-run'] });
console.log(`✓ Grocy sync: ok=${r.ok} fail=${r.fail}`);
for (const it of r.items) {
console.log(` [${it.status}] ${it.product_id} ${it.name} ${it.amount}`);
}
}
function parseArgs(argv) {
const a = {};
for (const x of argv) {
const m = x.match(/^--([^=]+)(?:=(.*))?$/);
if (m) a[m[1]] = m[2] ?? true;
}
return a;
}
function isoDaysAgo(d) { return new Date(Date.now() - d * 86400 * 1000).toISOString().slice(0, 10); }
+84
View File
@@ -0,0 +1,84 @@
// server/src/services/grocyClient.js — Grocy REST 客户端(API Key + Session Cookie 双支持)
import { http, httpGet, httpPost } from '../http.js';
// Session cookie 缓存(session 鉴权模式)
let _cookie = null;
let _cookieAt = 0;
let _loginInFlight = null;
const COOKIE_TTL_MS = 24 * 60 * 60 * 1000;
/**
* POST /login 拿 session cookieGrocy 默认鉴权方式之一)
* @param {object} cfg
* @returns {Promise<string>} cookie value
*/
async function loginGrocy(cfg) {
if (!cfg.grocy.url || !cfg.grocy.basic_user || !cfg.grocy.basic_pass) {
throw new Error('Grocy: URL / 用户名 / 密码 至少有一个没配置');
}
const body = new URLSearchParams({
username: cfg.grocy.basic_user,
password: cfg.grocy.basic_pass,
}).toString();
const res = await fetch(cfg.grocy.url + 'login', {
method: 'POST',
redirect: 'manual',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (res.status !== 302 && res.status !== 200) {
throw new Error(`Grocy login 失败 HTTP ${res.status}`);
}
const sc = res.headers.get('set-cookie') || '';
const m = sc.match(/(?:^|,\s*)grocy_session=([^;]+)/);
if (!m) throw new Error('Grocy login 响应里没找到 grocy_session cookie');
_cookie = `grocy_session=${m[1]}`;
_cookieAt = Date.now();
return _cookie;
}
async function ensureCookie(cfg) {
if (_cookie && Date.now() - _cookieAt < COOKIE_TTL_MS) return _cookie;
if (_loginInFlight) return _loginInFlight;
_loginInFlight = loginGrocy(cfg).finally(() => { _loginInFlight = null; });
return _loginInFlight;
}
/**
* Grocy REST GET
* 优先使用 API KeyGROCY-API-KEY header);无 Key 时降级到 session cookie
*/
export async function grocyGet(cfg, path, opts = {}) {
const timeout = opts.timeout || 30000;
const hasApiKey = !!(cfg.grocy.api_key);
const headers = {};
if (hasApiKey) {
headers['GROCY-API-KEY'] = cfg.grocy.api_key;
} else {
headers['Cookie'] = await ensureCookie(cfg);
}
try {
return await httpGet(cfg.grocy.url + path, { headers, timeout });
} catch (e) {
// API Key 模式下不重试
if (hasApiKey || (e.status !== 401 && e.status !== 403)) throw e;
// Session 模式下 401 强制重登一次
_cookie = null;
headers['Cookie'] = await ensureCookie(cfg);
return await httpGet(cfg.grocy.url + path, { headers, timeout });
}
}
/**
* Grocy REST POST
*/
export async function grocyPost(cfg, path, body) {
const hasApiKey = !!(cfg.grocy.api_key);
const headers = hasApiKey ? { 'GROCY-API-KEY': cfg.grocy.api_key } : { 'Cookie': await ensureCookie(cfg) };
return await httpPost(cfg.grocy.url + path, body, { headers, timeout: 10000 });
}
/** 重置(测试用) */
export function _reset() { _cookie = null; _cookieAt = 0; }
+160
View File
@@ -0,0 +1,160 @@
// server/src/services/grocyProducts.js — 从 Grocy 拉产品主数据 + 库存,写入本地缓存
import { grocyGet } from './grocyClient.js';
import { db } from '../db.js';
const TIMEOUT = 30000;
/** 在 grocy_sync_logs 里写入一条 started 记录,返回 logId */
async function startLog(action) {
const r = await db().run(
`INSERT INTO grocy_sync_logs (action, status, started_at) VALUES (?, 'running', NOW())`, [action]);
return r.lastInsertRowid;
}
/** 更新日志状态 */
async function finishLog(logId, status, okCount, failCount, detail) {
await db().run(
`UPDATE grocy_sync_logs SET status = ?, ok_count = ?, fail_count = ?, detail = ?, finished_at = NOW() WHERE id = ?`,
[status, okCount, failCount, detail ? JSON.stringify(detail) : null, logId]);
}
/**
* 从 Grocy 拉产品 + 库存,upsert 到本地 chemicals 表
*/
export async function pullProducts(cfg, opts = {}) {
if (!cfg.grocy.url) throw new Error('未配置 GROCY_URL');
const logId = opts.dryRun ? null : await startLog('pull_products');
let result;
try {
const [allProducts, stock, qu, pg, loc] = await Promise.all([
grocyGet(cfg, 'api/objects/products', { timeout: TIMEOUT }).catch(() => []),
grocyGet(cfg, 'api/stock', { timeout: 60000 }).catch(() => []),
grocyGet(cfg, 'api/objects/quantity_units', { timeout: 10000 }).catch(() => []),
grocyGet(cfg, 'api/objects/product_groups', { timeout: 10000 }).catch(() => []),
grocyGet(cfg, 'api/objects/locations', { timeout: 10000 }).catch(() => []),
]);
if (!Array.isArray(allProducts) && !Array.isArray(stock)) {
throw new Error('Grocy /api/objects/products 和 /api/stock 都返回非数组');
}
const quById = Object.fromEntries(qu.map(x => [Number(x.id), x]));
const pgById = Object.fromEntries(pg.map(x => [Number(x.id), x]));
const locById = Object.fromEntries(loc.map(x => [Number(x.id), x]));
const stockByProductId = {};
for (const s of stock) {
const p = s.product;
if (!p) continue;
stockByProductId[String(p.id)] = s;
}
const pulledIds = new Set();
for (const p of allProducts) { if (p && p.id) pulledIds.add(String(p.id)); }
let inserted = 0, updated = 0, deactivated = 0;
let totalAmount = 0, totalValue = 0;
const byGroup = {};
// 事务中处理
await db().transaction(async (tx) => {
for (const p of allProducts) {
if (!p || !p.id) continue;
const productId = String(p.id);
const exist = await tx.get('SELECT 1 FROM chemicals WHERE grocy_product_id = ?', [productId]);
const s = stockByProductId[productId];
const quName = quById[p.qu_id_stock]?.name || '';
const consumeQuName = quById[p.qu_id_consume]?.name || '';
const pgName = pgById[p.product_group_id]?.name || '';
const locName = locById[p.location_id]?.name || '';
const amount = s ? Number(s.amount || 0) : 0;
const value = s ? Number(s.value || 0) : 0;
const bestBefore = s ? (s.best_before_date || null) : null;
const quFactor = Number(p.userfields?.qu_factor ?? 1.0);
await tx.run(`
INSERT INTO chemicals
(grocy_product_id, name, description, category, unit, current_amount,
current_value, min_stock_amount, best_before_date, location,
product_group_id, qu_id, location_id, picture_file_name,
qu_factor, consume_unit_id, consume_unit_name,
source, is_active, fetched_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'grocy', 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE
name = VALUES(name),
description = VALUES(description),
category = VALUES(category),
unit = VALUES(unit),
current_amount = VALUES(current_amount),
current_value = VALUES(current_value),
min_stock_amount = VALUES(min_stock_amount),
best_before_date = VALUES(best_before_date),
location = VALUES(location),
product_group_id = VALUES(product_group_id),
qu_id = VALUES(qu_id),
location_id = VALUES(location_id),
picture_file_name = VALUES(picture_file_name),
qu_factor = CASE WHEN VALUES(qu_factor) > 1 THEN VALUES(qu_factor) ELSE chemicals.qu_factor END,
consume_unit_id = COALESCE(chemicals.consume_unit_id, VALUES(consume_unit_id)),
consume_unit_name = COALESCE(NULLIF(chemicals.consume_unit_name, ''), VALUES(consume_unit_name)),
source = 'grocy',
is_active = 1,
fetched_at = NOW(),
updated_at = NOW()`,
[productId, p.name || productId, p.description || null,
pgName || (p.product_group_id ? `group-${p.product_group_id}` : null),
quName || (p.qu_id_stock ? `qu-${p.qu_id_stock}` : null),
amount, value, Number(p.min_stock_amount || 0), bestBefore,
locName || (p.location_id ? `loc-${p.location_id}` : null),
p.product_group_id || null, p.qu_id_stock || null, p.location_id || null,
p.picture_file_name || null, quFactor, p.qu_id_consume || null, consumeQuName]);
if (exist) updated++; else inserted++;
totalAmount += amount;
totalValue += value;
const g = pgName || `group-${p.product_group_id || '?'}`;
byGroup[g] = (byGroup[g] || 0) + 1;
}
// 停用 Grocy 里已删除的产品
if (pulledIds.size > 0) {
const placeholders = [...pulledIds].map(() => '?').join(',');
const r = await tx.run(`
UPDATE chemicals SET is_active = 0, updated_at = NOW()
WHERE source = 'grocy' AND is_active = 1 AND grocy_product_id NOT IN (${placeholders})`,
[...pulledIds]);
deactivated = r.changes;
}
});
result = {
products_total: allProducts.length,
stock_entries: stock.length,
inserted, updated, deactivated,
total_amount: totalAmount, total_value: totalValue,
groups: byGroup,
};
} catch (err) {
if (logId) await finishLog(logId, 'failed', 0, 0, { error: err.message });
throw err;
}
if (logId) await finishLog(logId, 'success', result.inserted + result.updated, result.deactivated, result);
return result;
}
export async function cli(argv, cfg) {
if (argv.includes('--help') || argv.includes('-h')) {
console.log(`Usage: grocy-refresh-products [--dry-run]`);
return;
}
const dryRun = argv.includes('--dry-run');
const r = await pullProducts(cfg, { dryRun });
console.log(`✓ Grocy pull: products=${r.products_total} stock_entries=${r.stock_entries} inserted=${r.inserted} updated=${r.updated}`);
console.log(` 累计库存: ${r.total_amount.toFixed(2)} 单位`);
console.log(` 累计价值: ¥${r.total_value.toFixed(2)}`);
console.log(` 分类分布:`);
for (const [g, c] of Object.entries(r.groups).sort((a, b) => b[1] - a[1])) {
console.log(` ${g}: ${c}`);
}
}
+99
View File
@@ -0,0 +1,99 @@
// server/src/services/grocyWrite.js — Grocy 写入(创建产品 / 入库 / 扣减 / 盘点)
import { grocyGet } from './grocyClient.js';
/**
* 在 Grocy 创建一个新 product
* @param {object} cfg
* @param {object} data
* - name (必填)
* - description?
* - product_group_id? (默认 null)
* - qu_id_stock? (默认 1)
* - qu_id_purchase? (默认同 qu_id_stock)
* - qu_factor_purchase_to_stock? (默认 1)
* - location_id? (默认 1)
* - shopping_location_id? (默认同 location_id)
* - min_stock_amount? (默认 0)
* - default_best_before_days? (默认 0)
* @returns {Promise<{id: number, name: string}>}
*/
export async function createGrocyProduct(cfg, data) {
if (!data.name) throw new Error('name 必填');
const body = {
name: data.name,
description: data.description || null,
product_group_id: data.product_group_id || null,
qu_id_stock: Number(data.qu_id_stock || 1),
qu_id_purchase: Number(data.qu_id_purchase || data.qu_id_stock || 1),
location_id: Number(data.location_id || 1),
shopping_location_id: Number(data.shopping_location_id || data.location_id || 1),
min_stock_amount: Number(data.min_stock_amount || 0),
default_best_before_days: Number(data.default_best_before_days || 0),
};
// 4.6+ 字段,4.5.x 不支持
if (data.qu_factor_purchase_to_stock) body.qu_factor_purchase_to_stock = Number(data.qu_factor_purchase_to_stock);
return await post(cfg, 'api/objects/products', body);
}
/**
* 在 Grocy 入库(采购)
* @param {object} cfg
* @param {number} productId
* @param {object} data
* - amount (必填)
* - price? (默认 0)
* - best_before_date? (默认 null)
* - transaction_type? 'purchase' | 'inventory' | 'self_production' (默认 purchase)
* - note?
*/
export async function addGrocyStock(cfg, productId, data) {
if (!data.amount || Number(data.amount) <= 0) throw new Error('amount 必填且 > 0');
const body = {
amount: Number(data.amount),
price: Number(data.price || 0),
best_before_date: data.best_before_date || null,
transaction_type: data.transaction_type || 'purchase',
note: data.note || null,
};
return await post(cfg, `api/stock/products/${productId}/add`, body);
}
/**
* 在 Grocy 扣减库存(洗车用)
* @param {object} cfg
* @param {number} productId
* @param {object} data
* - amount (必填)
* - transaction_type? 默认 'consume'
* - note?
* - recipe_id? (可选)
*/
export async function consumeGrocyStock(cfg, productId, data) {
if (!data.amount || Number(data.amount) <= 0) throw new Error('amount 必填且 > 0');
const body = {
amount: Number(data.amount),
transaction_type: data.transaction_type || 'consume',
note: data.note || null,
};
return await post(cfg, `api/stock/products/${productId}/consume`, body);
}
/**
* 在 Grocy 盘点(修正库存)
*/
export async function inventoryGrocyStock(cfg, productId, data) {
if (data.new_amount == null) throw new Error('new_amount 必填');
const body = {
new_amount: Number(data.new_amount),
best_before_date: data.best_before_date || null,
transaction_type: 'inventory',
note: data.note || null,
};
return await post(cfg, `api/stock/products/${productId}/inventory`, body);
}
// 内部:POST
async function post(cfg, path, body) {
const { grocyPost } = await import('./grocyClient.js');
return await grocyPost(cfg, path, body);
}
+403
View File
@@ -0,0 +1,403 @@
// server/src/services/monthlyReport.js — 月度报表(Excel + PDF
import ExcelJS from 'exceljs';
import PDFDocument from 'pdfkit';
import { db } from '../db.js';
const FONT_PATH_HANS = null; // 留空:默认字体(PDF 不带中文)
/**
* 聚合指定月份的所有数据
* @param {string} month YYYY-MM
* @returns {object} { month, vehicles, washes, refuels, charges, maints, insurances, totals }
*/
export async function gatherMonth(month) {
// 月份范围
const [y, m] = month.split('-').map(Number);
const from = `${month}-01`;
const to = new Date(y, m, 0).toISOString().slice(0, 10); // 月末
const prevMonth = m === 1 ? `${y-1}-12` : `${y}-${String(m-1).padStart(2,'0')}`;
// 车辆
const vehicles = await db().all(`SELECT id, name, plate, type, powertrain FROM vehicles WHERE is_active = 1 ORDER BY sort_order, id`);
// 洗车
const washes = await db().all(`
SELECT w.id, w.wash_date, w.wash_type, w.cost, w.location, w.vehicle_id, w.notes,
w.duration_min, v.name AS vehicle_name, v.plate AS vehicle_plate
FROM wash_records w
LEFT JOIN vehicles v ON v.id = w.vehicle_id
WHERE w.is_deleted = 0 AND w.wash_date BETWEEN ? AND ?
ORDER BY w.wash_date, w.id`, [from, to]);
// 加油
const refuels = await db().all(`
SELECT r.id, r.refuel_date, r.liters, r.price_per_liter, r.total_cost, r.fuel_type,
r.is_full, r.station, r.vehicle_id, r.odometer_km,
v.name AS vehicle_name, v.plate AS vehicle_plate
FROM refuel_records r
LEFT JOIN vehicles v ON v.id = r.vehicle_id
WHERE r.is_deleted = 0 AND r.refuel_date BETWEEN ? AND ?
ORDER BY r.refuel_date, r.id`, [from, to]);
// 充电
const charges = await db().all(`
SELECT c.id, c.charge_date, c.kwh, c.price_per_kwh, c.total_cost, c.charge_type,
c.start_soc, c.end_soc, c.station, c.vehicle_id, c.odometer_km,
v.name AS vehicle_name, v.plate AS vehicle_plate
FROM charging_records c
LEFT JOIN vehicles v ON v.id = c.vehicle_id
WHERE c.is_deleted = 0 AND c.charge_date BETWEEN ? AND ?
ORDER BY c.charge_date, c.id`, [from, to]);
// 保养
const maints = await db().all(`
SELECT m.id, m.maint_date, m.odometer_km, m.total_cost, m.shop, m.items_json,
m.next_due_km, m.next_due_date, m.vehicle_id, v.name AS vehicle_name, v.plate AS vehicle_plate
FROM maintenance_records m
LEFT JOIN vehicles v ON v.id = m.vehicle_id
WHERE m.is_deleted = 0 AND m.maint_date BETWEEN ? AND ?
ORDER BY m.maint_date, m.id`, [from, to]);
// 保险
const insurances = await db().all(`
SELECT i.id, i.insurance_type, i.company, i.policy_no, i.start_date, i.end_date,
i.premium, i.vehicle_id, v.name AS vehicle_name, v.plate AS vehicle_plate
FROM insurance_records i
LEFT JOIN vehicles v ON v.id = i.vehicle_id
WHERE i.is_deleted = 0 AND (
(i.start_date BETWEEN ? AND ?) OR (i.end_date BETWEEN ? AND ?)
)
ORDER BY i.start_date`, [from, to, from, to]);
// 化学品 Top
const chemTop = await db().all(`
SELECT c.name, c.unit, SUM(cu.amount) AS total_amount, COUNT(*) AS count
FROM chemical_usage cu
LEFT JOIN chemicals c ON c.grocy_product_id = cu.chemical_id
WHERE cu.usage_date BETWEEN ? AND ?
GROUP BY cu.chemical_id, c.name, c.unit
ORDER BY total_amount DESC
LIMIT 10`, [from, to]);
// 汇总
const totals = {
wash: washes.reduce((s, r) => s + (Number(r.cost) || 0), 0),
refuel: refuels.reduce((s, r) => s + (Number(r.total_cost) || 0), 0),
charge: charges.reduce((s, r) => s + (Number(r.total_cost) || 0), 0),
maint: maints.reduce((s, r) => s + (Number(r.total_cost) || 0), 0),
insurance: insurances.reduce((s, r) => s + (Number(r.premium) || 0), 0),
refuel_liters: refuels.reduce((s, r) => s + (Number(r.liters) || 0), 0),
charge_kwh: charges.reduce((s, r) => s + (Number(r.kwh) || 0), 0),
wash_count: washes.length,
refuel_count: refuels.length,
charge_count: charges.length,
maint_count: maints.length,
};
totals.grand = totals.wash + totals.refuel + totals.charge + totals.maint + totals.insurance;
return {
month, from, to,
vehicles,
washes, refuels, charges, maints, insurances,
chemTop,
totals,
prevMonth,
};
}
/**
* 生成 Excel 月度报表(多 sheet
* @param {string} month YYYY-MM
* @returns {Promise<Buffer>} xlsx 文件 buffer
*/
export async function buildExcel(month) {
const data = await gatherMonth(month);
const wb = new ExcelJS.Workbook();
wb.creator = '洗车管理系统';
wb.created = new Date();
// === Sheet 1: 汇总 ===
const summary = wb.addWorksheet('汇总', { properties: { tabColor: { argb: 'FF4DBA9A' } } });
summary.columns = [
{ header: '项目', key: 'item', width: 16 },
{ header: '次数', key: 'count', width: 10 },
{ header: '量', key: 'amount', width: 12 },
{ header: '金额 (¥)', key: 'cost', width: 14 },
];
summary.getRow(1).font = { bold: true, size: 12 };
summary.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0F0E8' } };
summary.addRows([
{ item: `${month} 用车汇总`, count: '', amount: '', cost: '' },
{ item: '洗车', count: data.totals.wash_count, amount: '—', cost: data.totals.wash },
{ item: '加油', count: data.totals.refuel_count, amount: `${data.totals.refuel_liters.toFixed(1)} L`, cost: data.totals.refuel },
{ item: '充电', count: data.totals.charge_count, amount: `${data.totals.charge_kwh.toFixed(1)} kWh`, cost: data.totals.charge },
{ item: '保养', count: data.totals.maint_count, amount: '—', cost: data.totals.maint },
{ item: '保险', count: data.insurances.length, amount: '—', cost: data.totals.insurance },
]);
// 总计行
const totalRow = summary.addRow({
item: '总计',
count: '',
amount: '',
cost: { formula: `SUM(E3:E${summary.rowCount - 1})` },
});
totalRow.font = { bold: true, color: { argb: 'FFFFFFFF' } };
totalRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E5B8A' } };
totalRow.getCell('cost').numFmt = '¥#,##0.00';
// 数字格式
summary.getColumn('cost').numFmt = '¥#,##0.00';
summary.getColumn('count').alignment = { horizontal: 'right' };
// === Sheet 2: 按车辆分组 ===
const byCar = wb.addWorksheet('按车辆', { properties: { tabColor: { argb: 'FF1E5B8A' } } });
byCar.columns = [
{ header: '车辆', key: 'name', width: 20 },
{ header: '车牌', key: 'plate', width: 12 },
{ header: '洗车次', key: 'wash_count', width: 10 },
{ header: '洗车费', key: 'wash_cost', width: 12 },
{ header: '加油费', key: 'refuel_cost', width: 12 },
{ header: '充电费', key: 'charge_cost', width: 12 },
{ header: '保养费', key: 'maint_cost', width: 12 },
{ header: '合计', key: 'total', width: 14 },
];
byCar.getRow(1).font = { bold: true };
byCar.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0F0E8' } };
byCar.getRow(1).alignment = { horizontal: 'center' };
for (const v of data.vehicles) {
const wCost = data.washes.filter(x => x.vehicle_id === v.id).reduce((s, r) => s + (r.cost || 0), 0);
const rCost = data.refuels.filter(x => x.vehicle_id === v.id).reduce((s, r) => s + (r.total_cost || 0), 0);
const cCost = data.charges.filter(x => x.vehicle_id === v.id).reduce((s, r) => s + (r.total_cost || 0), 0);
const mCost = data.maints.filter(x => x.vehicle_id === v.id).reduce((s, r) => s + (r.total_cost || 0), 0);
byCar.addRow({
name: v.name, plate: v.plate,
wash_count: data.washes.filter(x => x.vehicle_id === v.id).length,
wash_cost: wCost, refuel_cost: rCost, charge_cost: cCost, maint_cost: mCost,
total: wCost + rCost + cCost + mCost,
});
}
['wash_cost','refuel_cost','charge_cost','maint_cost','total'].forEach(k => byCar.getColumn(k).numFmt = '¥#,##0.00');
// === Sheet 3-7: 各领域明细 ===
addWashSheet(wb, data.washes);
addRefuelSheet(wb, data.refuels);
addChargeSheet(wb, data.charges);
addMaintSheet(wb, data.maints);
addInsuranceSheet(wb, data.insurances);
// === Sheet 8: 化学品 Top ===
const chem = wb.addWorksheet('化学品 Top');
chem.columns = [
{ header: '名称', key: 'name', width: 30 },
{ header: '用量', key: 'amount', width: 14 },
{ header: '单位', key: 'unit', width: 8 },
{ header: '使用次数', key: 'count', width: 12 },
];
chem.getRow(1).font = { bold: true };
chem.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0F0E8' } };
for (const c of data.chemTop) {
chem.addRow({ name: c.name, amount: Number(c.total_amount).toFixed(2), unit: c.unit || '', count: c.count });
}
const buf = await wb.xlsx.writeBuffer();
return Buffer.from(buf);
}
/**
* 生成 PDF 月度报表
* @param {string} month YYYY-MM
* @returns {Promise<Buffer>} pdf 文件 buffer
*/
export async function buildPdf(month) {
const data = await gatherMonth(month);
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 40 });
const chunks = [];
doc.on('data', c => chunks.push(c));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// 标题
doc.fontSize(20).text(`Monthly Vehicle Report`, { align: 'center' });
doc.fontSize(14).text(`${month}`, { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(9).fillColor('#666').text(`Generated: ${new Date().toLocaleString('zh-CN')}`, { align: 'center' });
doc.fillColor('#000');
doc.moveDown(1.5);
// 汇总表
doc.fontSize(13).text('Summary', { underline: true });
doc.moveDown(0.3);
doc.fontSize(10);
const sumY = doc.y;
const sumX = doc.x;
const colW = [200, 80, 100, 100];
drawRow(doc, sumY, sumX, colW, ['Item', 'Count', 'Amount', 'Cost (CNY)'], true);
let y = sumY + 18;
drawRow(doc, y, sumX, colW, ['Wash', String(data.totals.wash_count), '-', `CNY ${data.totals.wash.toFixed(2)}`]); y += 18;
drawRow(doc, y, sumX, colW, ['Refuel', String(data.totals.refuel_count), `${data.totals.refuel_liters.toFixed(1)} L`, `CNY ${data.totals.refuel.toFixed(2)}`]); y += 18;
drawRow(doc, y, sumX, colW, ['Charge', String(data.totals.charge_count), `${data.totals.charge_kwh.toFixed(1)} kWh`, `CNY ${data.totals.charge.toFixed(2)}`]); y += 18;
drawRow(doc, y, sumX, colW, ['Maintenance', String(data.totals.maint_count), '-', `CNY ${data.totals.maint.toFixed(2)}`]); y += 18;
drawRow(doc, y, sumX, colW, ['Insurance', String(data.insurances.length), '-', `CNY ${data.totals.insurance.toFixed(2)}`]); y += 18;
doc.moveTo(sumX, y + 4).lineTo(sumX + colW.reduce((a,b) => a+b, 0), y + 4).stroke();
y += 10;
drawRow(doc, y, sumX, colW, ['TOTAL', '', '', `CNY ${data.totals.grand.toFixed(2)}`], true);
doc.y = y + 30;
// 按车辆
doc.fontSize(13).text('By Vehicle', { underline: true });
doc.moveDown(0.3);
doc.fontSize(10);
const vY = doc.y;
const vX = doc.x;
const vColW = [120, 60, 60, 80, 80, 80];
drawRow(doc, vY, vX, vColW, ['Vehicle', 'Plate', 'Wash', 'Refuel', 'Charge', 'Maint'], true);
let vy = vY + 18;
for (const v of data.vehicles) {
const wCost = data.washes.filter(x => x.vehicle_id === v.id).reduce((s, r) => s + (Number(r.cost) || 0), 0);
const rCost = data.refuels.filter(x => x.vehicle_id === v.id).reduce((s, r) => s + (Number(r.total_cost) || 0), 0);
const cCost = data.charges.filter(x => x.vehicle_id === v.id).reduce((s, r) => s + (Number(r.total_cost) || 0), 0);
const mCost = data.maints.filter(x => x.vehicle_id === v.id).reduce((s, r) => s + (Number(r.total_cost) || 0), 0);
drawRow(doc, vy, vX, vColW, [
truncate(v.name, 18), truncate(v.plate || '-', 8),
`CNY ${wCost.toFixed(0)}`, `CNY ${rCost.toFixed(0)}`, `CNY ${cCost.toFixed(0)}`, `CNY ${mCost.toFixed(0)}`
]); vy += 18;
if (vy > 720) { doc.addPage(); vy = 50; }
}
doc.y = vy + 12;
// 明细
if (doc.y > 650) doc.addPage();
doc.fontSize(13).text('Wash Records');
doc.moveDown(0.3);
doc.fontSize(9);
for (const w of data.washes) {
doc.text(`${w.wash_date} ${(w.vehicle_name || '-')} ${(w.wash_type || '')} CNY ${(Number(w.cost) || 0).toFixed(2)} ${(w.location || '')}`);
if (doc.y > 770) doc.addPage();
}
doc.end();
});
}
function truncate(s, n) { return s.length > n ? s.slice(0, n-1) + '…' : s; }
function drawRow(doc, y, x, colW, cells, bold) {
let cx = x;
for (let i = 0; i < cells.length; i++) {
doc.font('Helvetica').fontSize(bold ? 10 : 9);
if (bold) doc.font('Helvetica-Bold');
doc.text(cells[i], cx + 4, y + 4, { width: colW[i] - 8, align: i === 0 ? 'left' : (i === cells.length-1 ? 'right' : 'left'), ellipsis: true });
cx += colW[i];
}
}
function addWashSheet(wb, rows) {
const ws = wb.addWorksheet('洗车明细');
ws.columns = [
{ header: '日期', key: 'wash_date', width: 12 },
{ header: '车辆', key: 'vehicle_name', width: 18 },
{ header: '车牌', key: 'vehicle_plate', width: 12 },
{ header: '类型', key: 'wash_type', width: 10 },
{ header: '花费', key: 'cost', width: 10 },
{ header: '位置', key: 'location', width: 16 },
{ header: '耗时(分)', key: 'duration_min', width: 10 },
{ header: '备注', key: 'notes', width: 30 },
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0F0E8' } };
for (const r of rows) ws.addRow({ ...r, cost: Number(r.cost) });
ws.getColumn('cost').numFmt = '¥#,##0.00';
}
function addRefuelSheet(wb, rows) {
const ws = wb.addWorksheet('加油明细');
ws.columns = [
{ header: '日期', key: 'refuel_date', width: 12 },
{ header: '车辆', key: 'vehicle_name', width: 18 },
{ header: '车牌', key: 'vehicle_plate', width: 12 },
{ header: '油号', key: 'fuel_type', width: 8 },
{ header: '升数', key: 'liters', width: 10 },
{ header: '单价', key: 'price_per_liter', width: 10 },
{ header: '总价', key: 'total_cost', width: 12 },
{ header: '是否加满', key: 'is_full', width: 10 },
{ header: '油耗', key: 'consumption_100km', width: 10 },
{ header: '里程', key: 'odometer_km', width: 10 },
{ header: '油站', key: 'station', width: 18 },
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0F0E8' } };
for (const r of rows) ws.addRow({
...r,
is_full: r.is_full ? '是' : '否',
consumption_100km: r.consumption_100km ? r.consumption_100km.toFixed(2) + ' L/100km' : '—',
});
ws.getColumn('total_cost').numFmt = '¥#,##0.00';
ws.getColumn('price_per_liter').numFmt = '¥#,##0.00';
}
function addChargeSheet(wb, rows) {
const ws = wb.addWorksheet('充电明细');
ws.columns = [
{ header: '日期', key: 'charge_date', width: 12 },
{ header: '车辆', key: 'vehicle_name', width: 18 },
{ header: '车牌', key: 'vehicle_plate', width: 12 },
{ header: '类型', key: 'charge_type', width: 10 },
{ header: '度数', key: 'kwh', width: 10 },
{ header: '单价', key: 'price_per_kwh', width: 10 },
{ header: '总价', key: 'total_cost', width: 12 },
{ header: 'SOC', key: 'soc', width: 12 },
{ header: '电耗', key: 'kwh_per_100km', width: 10 },
{ header: '地点', key: 'station', width: 18 },
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0F0E8' } };
for (const r of rows) ws.addRow({
...r,
soc: (r.start_soc != null && r.end_soc != null) ? `${r.start_soc}${r.end_soc}%` : '—',
kwh_per_100km: r.kwh_per_100km ? r.kwh_per_100km.toFixed(2) + ' kWh/100km' : '—',
});
ws.getColumn('total_cost').numFmt = '¥#,##0.00';
ws.getColumn('price_per_kwh').numFmt = '¥#,##0.00';
}
function addMaintSheet(wb, rows) {
const ws = wb.addWorksheet('保养明细');
ws.columns = [
{ header: '日期', key: 'maint_date', width: 12 },
{ header: '车辆', key: 'vehicle_name', width: 18 },
{ header: '车牌', key: 'vehicle_plate', width: 12 },
{ header: '里程', key: 'odometer_km', width: 10 },
{ header: '店家', key: 'shop', width: 18 },
{ header: '项目', key: 'items_text', width: 40 },
{ header: '总价', key: 'total_cost', width: 12 },
{ header: '下次里程', key: 'next_due_km', width: 12 },
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0F0E8' } };
for (const r of rows) {
let itemsText = '';
try { itemsText = (r.items_json ? JSON.parse(r.items_json) : []).map(i => `${i.name}¥${i.cost||0}`).join(' / '); } catch {}
ws.addRow({ ...r, items_text: itemsText });
}
ws.getColumn('total_cost').numFmt = '¥#,##0.00';
}
function addInsuranceSheet(wb, rows) {
const ws = wb.addWorksheet('保单明细');
ws.columns = [
{ header: '生效日', key: 'start_date', width: 12 },
{ header: '到期日', key: 'end_date', width: 12 },
{ header: '车辆', key: 'vehicle_name', width: 18 },
{ header: '险种', key: 'insurance_type', width: 14 },
{ header: '公司', key: 'company', width: 14 },
{ header: '保单号', key: 'policy_no', width: 20 },
{ header: '保费', key: 'premium', width: 12 },
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0F0E8' } };
for (const r of rows) ws.addRow({ ...r, premium: Number(r.premium) });
ws.getColumn('premium').numFmt = '¥#,##0.00';
}
+37
View File
@@ -0,0 +1,37 @@
// server/src/services/operationLog.js — 统一的"写操作日志"入口(fire-and-forget
import { db } from '../db.js';
/**
* 写一条操作日志(fire-and-forget,主业务不等待)
* @param {object} e
* @param {object} e.req - Express req,用来取 user / ip / ua
* @param {string} e.action - 'delete' | 'batch_delete' | 'create' | 'update' ...
* @param {string} e.targetType - 'wash_record' | ...
* @param {number[]|number} e.targetIds
* @param {string} [e.summary] - 人类可读摘要
* @param {object} [e.detail] - 任意 JSON 详情(删除前的快照等)
*/
export function logOperation(e) {
setImmediate(async () => {
try {
let username = null;
const u = e.req?.session?.userId;
if (u) {
const row = await db().get('SELECT username FROM users WHERE id = ?', [u]);
username = row?.username || null;
}
const ids = Array.isArray(e.targetIds) ? e.targetIds : [e.targetIds];
await db().run(`
INSERT INTO operation_logs
(user_id, username, action, target_type, target_ids, target_summary, detail_json, ip, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[u || null, username, String(e.action), String(e.targetType),
JSON.stringify(ids.filter(x => x != null)),
e.summary || null, e.detail ? JSON.stringify(e.detail) : null,
e.req?.ip || null,
(e.req?.get?.('user-agent') || '').slice(0, 250) || null]);
} catch (err) {
console.error('[operationLog] failed:', err.message);
}
});
}
+107
View File
@@ -0,0 +1,107 @@
// server/src/services/rateLimit.js — 登录防撞库(MySQL 兼容)
import { db } from '../db.js';
const HOUR = 3600 * 1000;
const MIN = 60 * 1000;
// MySQL DATETIME format: 'YYYY-MM-DD HH:MM:SS'
// 注意:db.js 配的是 `timezone: 'Z'`UTC),所以写入必须用 UTC 时间,
// 否则 mysql2 读回时会按 UTC 解析,造成 8 小时时差(参考:trae 时期 bug2026-06-19 发现)
function nowIso() {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
}
/** 检查 IP/用户名是否被锁 */
export async function isLocked(ip, username) {
const rows = await db().all(
`SELECT lock_key, lock_type, target, locked_until, reason
FROM auth_locks
WHERE locked_until > ?
AND (lock_key = ? OR lock_key = ?)`,
[nowIso(), 'ip:' + ip, 'user:' + username.toLowerCase()]
);
const locked = { ip: null, user: null };
for (const r of rows) locked[r.lock_type] = { until: r.locked_until, reason: r.reason };
return locked;
}
export async function recordFailure(ip, username, userAgent, reason, cfg) {
await db().run(
`INSERT INTO login_attempts (ip_address, username, success, user_agent, failure_reason)
VALUES (?, ?, 0, ?, ?)`,
[ip, username.toLowerCase(), String(userAgent || '').slice(0, 256), reason]
);
const ipCount = await recentFailuresByIp(ip, cfg.login_lock_minutes_ip * MIN);
if (ipCount >= cfg.login_max_failures_ip) await lockIp(ip, cfg.login_lock_minutes_ip, ipCount, 'too_many_failures');
const uCount = await recentFailuresByUsername(username, cfg.login_lock_minutes_user * MIN);
if (uCount >= cfg.login_max_failures_user) await lockUser(username, cfg.login_lock_minutes_user, uCount, 'too_many_failures');
const gCount = await recentFailuresByIp(ip, cfg.login_global_lock_hours * HOUR);
if (gCount >= cfg.login_global_max_failures) await lockIp(ip, cfg.login_global_lock_hours * 60, gCount, 'global_threshold');
}
export async function recordSuccess(ip, username, userAgent) {
await db().run(
`INSERT INTO login_attempts (ip_address, username, success, user_agent) VALUES (?, ?, 1, ?)`,
[ip, username.toLowerCase(), String(userAgent || '').slice(0, 256)]
);
}
export async function recentFailuresByIp(ip, windowMs) {
const since = new Date(Date.now() - windowMs).toISOString().replace(/\.\d{3}Z$/, 'Z');
const r = await db().get(
`SELECT COUNT(*) AS c FROM login_attempts
WHERE ip_address = ? AND success = 0 AND attempted_at >= ?`,
[ip, since]
);
return r ? r.c : 0;
}
export async function recentFailuresByUsername(username, windowMs) {
const since = new Date(Date.now() - windowMs).toISOString().replace(/\.\d{3}Z$/, 'Z');
const r = await db().get(
`SELECT COUNT(*) AS c FROM login_attempts
WHERE LOWER(username) = LOWER(?) AND success = 0 AND attempted_at >= ?`,
[username, since]
);
return r ? r.c : 0;
}
async function lockIp(ip, minutes, attempts, reason) {
await upsertLock('ip:' + ip, 'ip', ip, minutes, reason, attempts);
}
async function lockUser(username, minutes, attempts, reason = 'too_many_failures') {
await upsertLock('user:' + username.toLowerCase(), 'user', username.toLowerCase(), minutes, reason, attempts);
}
async function upsertLock(key, type, target, minutes, reason, attempts) {
const until = new Date(Date.now() + minutes * MIN);
const pad = n => String(n).padStart(2, '0');
// 用 UTC 方法,与 nowIso() 和 db.js `timezone: 'Z'` 一致
const untilStr = `${until.getUTCFullYear()}-${pad(until.getUTCMonth()+1)}-${pad(until.getUTCDate())} ${pad(until.getUTCHours())}:${pad(until.getUTCMinutes())}:${pad(until.getUTCSeconds())}`;
await db().run(
`INSERT INTO auth_locks (lock_key, lock_type, target, locked_until, reason, attempts)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
locked_until = VALUES(locked_until),
reason = VALUES(reason),
attempts = VALUES(attempts)`,
[key, type, target, untilStr, reason, attempts]
);
}
export async function cleanupLocks() {
const r = await db().run('DELETE FROM auth_locks WHERE locked_until <= ?', [nowIso()]);
return r.changes;
}
export async function cleanupAttempts(retentionDays) {
const since = new Date(Date.now() - retentionDays * 86400 * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
const r = await db().run('DELETE FROM login_attempts WHERE attempted_at < ?', [since]);
return r.changes;
}
+156
View File
@@ -0,0 +1,156 @@
// server/src/services/weather.js — 天气获取(wttr.in
import { httpGet } from '../http.js';
import { db } from '../db.js';
/** wttr.in weatherCode → 中文描述 */
const WTCODE_ZH = {
'113': '晴', '116': '多云',
'119': '阴', '122': '阴沉',
'143': '雾', '176': '局部阵雨',
'179': '阵雪', '182': '小雪',
'185': '冻雨', '200': '雷暴',
'227': '大风', '230': '暴雪',
'248': '霾', '260': '大雾',
'263': '小雨', '266': '小雨',
'271': '冻雾', '272': '大雾',
'281': '冰雨', '284': '中雨',
'293': '小雨', '296': '小雨',
'299': '中雨', '302': '中雨',
'305': '大雨', '308': '暴雨',
'311': '冰雨', '314': '中雨',
'317': '小雪', '320': '阵雪',
'323': '小雪', '326': '小雪',
'329': '中雪', '332': '中雪',
'335': '大雪', '338': '大雪',
'350': '冰雹', '353': '阵雨',
'356': '大雨', '359': '大雨',
'362': '小冰雹', '365': '小冰雹',
'368': '小雪', '371': '阵雪',
'374': '冰粒', '377': '冰粒',
'386': '雷阵雨', '389': '雷阵雨',
'392': '雷阵雪', '395': '大雪',
'500': '扬沙', '501': '扬沙',
'502': '沙尘暴', '503': '强扬沙',
'504': '极端扬沙', '511': '冻雾',
'140': '扬沙',
};
/** 天气描述中文 */
function zhDesc(code, fallback) {
return WTCODE_ZH[String(code)] || fallback || '未知';
}
/** 根据 IP 自动检测所在城市(用 ipinfo.io) */
export async function detectCityFromIp() {
try {
const r = await httpGet('https://ipinfo.io/json', { timeout: 5000 });
if (r && r.city) {
return { city: r.city, region: r.region, country: r.country, coords: r.loc };
}
} catch { /* 忽略 */ }
return null;
}
/**
* 拉取今日天气(wttr.in
* 优先级:app_city(当天手动锁定) > app_city_default(永久默认) > IP 定位
*/
export async function fetchToday(city, cfg) {
cfg = cfg || (await import('../config.js')).loadConfig();
const today = new Date().toISOString().slice(0, 10);
// 1. 当天手动设置的城市(当天有效)
if (city && city !== 'auto') {
const row = (await db().get("SELECT updated_at FROM settings WHERE `key` = 'app_city'"));
const setDate = row?.updated_at ? row.updated_at.slice(0, 10) : null;
if (setDate === today) {
console.log(`[weather] 使用当天手动城市: ${city}`);
return fetchFromWttr(city);
}
}
// 2. 用户设置的默认城市(永久)
const defaultCityRow = await db().get("SELECT value FROM settings WHERE `key` = 'app_city_default'");
const defaultCity = defaultCityRow?.value?.trim() || '';
if (defaultCity) {
console.log(`[weather] 使用默认城市: ${defaultCity}`);
return fetchFromWttr(defaultCity);
}
// 3. 自动 IP 定位
const detected = await detectCityFromIp();
const resolved = detected?.city || 'Beijing';
if (detected) {
console.log(`[weather] IP 定位: ${detected.city}, ${detected.region}, ${detected.country}`);
}
return fetchFromWttr(resolved);
}
async function fetchFromWttr(city) {
const url = `https://wttr.in/${encodeURIComponent(city)}?format=j1`;
const r = await httpGet(url, { timeout: 8000 });
if (!r || !r.current_condition || !r.current_condition[0]) {
throw new Error('wttr.in: 解析失败');
}
const cur = r.current_condition[0];
const resolvedCity = r.nearest_area?.[0]?.areaName?.[0]?.value || city;
const code = cur.weatherCode;
return {
city: resolvedCity,
provider: 'wttr',
weather_desc_en: cur.weatherDesc?.[0]?.value || 'Unknown',
weather_desc: zhDesc(code, cur.weatherDesc?.[0]?.value || '未知'),
temp_c: parseFloat(cur.temp_C),
humidity: parseInt(cur.humidity),
wind_kph: parseFloat(cur.windspeedKmph),
precip_mm: parseFloat(cur.precipMM),
weather_code: code,
fetched_at: new Date().toISOString(),
raw_json: JSON.stringify(r),
};
}
// ===== CLI 入口 =====
export async function cli(argv, cfg) {
if (argv.includes('--help') || argv.includes('-h')) {
console.log(`Usage: weather [--city=Beijing]
默认自动 IP 定位。手动设置城市当天有效,次日自动恢复 IP 定位。`);
return;
}
const args = parseArgs(argv);
cfg = cfg || (await import('../config.js')).loadConfig();
const city = args.city || cfg.app.city || 'auto';
const date = new Date().toISOString().slice(0, 10);
const w = await fetchToday(city, cfg);
const displayCity = w.city;
const exist = (await db().get("SELECT id FROM weather_snapshots WHERE city = ? AND snapshot_date = ?", [displayCity, date]));
if (exist) {
await db().run(`
UPDATE weather_snapshots
SET provider=?, temp_c=?, humidity=?, weather_desc=?,
weather_code=?, wind_kph=?, precip_mm=?, raw_json=?,
fetched_at=NOW()
WHERE id=?`,
['wttr', w.temp_c, w.humidity, w.weather_desc, w.weather_code,
w.wind_kph, w.precip_mm, w.raw_json, exist.id]);
console.log(`✓ Updated weather for ${displayCity} ${date}`);
} else {
const info = await db().run(`
INSERT INTO weather_snapshots (city, provider, temp_c, humidity, weather_desc, weather_code, wind_kph, precip_mm, raw_json, snapshot_date)
VALUES (?, 'wttr', ?, ?, ?, ?, ?, ?, ?, ?)`,
[displayCity, w.temp_c, w.humidity, w.weather_desc, w.weather_code,
w.wind_kph, w.precip_mm, w.raw_json, date]);
console.log(`✓ Inserted weather for ${displayCity} ${date} (id=${info.lastInsertRowid})`);
}
console.log(` ${w.weather_desc} · ${w.temp_c}℃ · 湿度 ${w.humidity}%`);
}
function parseArgs(argv) {
const a = {};
for (const x of argv) {
const m = x.match(/^--([^=]+)(?:=(.*))?$/);
if (m) a[m[1]] = m[2] ?? true;
}
return a;
}
+358
View File
@@ -0,0 +1,358 @@
// server/src/setup.js — 首次安装向导
import express from 'express';
import bcrypt from 'bcryptjs';
import mysql from 'mysql2/promise';
import path from 'node:path';
import fs from 'node:fs';
import url from 'node:url';
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const router = express.Router();
const SETUP_DONE_FILE = path.join(__dirname, '../../.setup_done');
// ============================================================
// GET /setup — 向导页面
// ============================================================
router.get('/setup', (req, res) => {
if (fs.existsSync(SETUP_DONE_FILE)) return res.redirect('/');
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(getSetupHTML());
});
// ============================================================
// POST /api/setup/test-db
// ============================================================
router.post('/api/setup/test-db', async (req, res) => {
const { host, port, user, password, database } = req.body || {};
if (!host || !database) return res.json({ ok: false, error: 'host 和 database 不能为空' });
try {
const conn = await mysql.createConnection({
host: host || '127.0.0.1', port: Number(port || 3306),
user: user || 'root', password: password || '',
connectTimeout: 5000,
});
await conn.query('CREATE DATABASE IF NOT EXISTS `' + database + '`');
await conn.query('USE `' + database + '`');
const [tables] = await conn.query('SHOW TABLES');
await conn.end();
res.json({ ok: true, empty: tables.length === 0, tableCount: tables.length });
} catch (e) { res.json({ ok: false, error: e.message }); }
});
// ============================================================
// POST /api/setup/init
// ============================================================
router.post('/api/setup/init', async (req, res) => {
const { host, port, user, password, database,
admin_username, admin_password,
grocy_url, grocy_username, grocy_password,
import_demo } = req.body || {};
if (!admin_username || !admin_password) return res.json({ ok: false, error: '管理员账号密码不能为空' });
if (admin_password.length < 6) return res.json({ ok: false, error: '密码至少 6 位' });
let conn;
try {
conn = await mysql.createConnection({
host: host || '127.0.0.1', port: Number(port || 3306),
user: user || 'root', password: password || '',
connectTimeout: 10000,
});
// 写 .env
const envPath = path.join(__dirname, '../../.env');
const secret = Array.from({ length: 48 }, () => 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'[Math.floor(Math.random() * 62)]).join('');
const envContent = [
'DB_HOST=' + (host || '127.0.0.1'),
'DB_PORT=' + (port || '3306'),
'DB_USER=' + (user || 'root'),
'DB_PASSWORD=' + (password || ''),
'DB_NAME=' + database,
'SESSION_SECRET=' + secret,
].join('\n');
fs.writeFileSync(envPath, envContent, 'utf8');
console.log('[setup] .env written');
// 建库(如不存在)
await conn.query('CREATE DATABASE IF NOT EXISTS `' + database + '` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
await conn.query('USE `' + database + '`');
console.log('[setup] database ready');
// 跑迁移
const migrationsDir = path.join(__dirname, '../migrations/mysql');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
await conn.query('CREATE TABLE IF NOT EXISTS schema_migrations (filename VARCHAR(255) PRIMARY KEY, applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)');
const [existing] = await conn.query('SELECT filename FROM schema_migrations');
const applied = new Set(existing.map(r => r.filename));
let appliedCount = 0;
for (const f of files) {
if (applied.has(f)) continue;
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
// Split into individual statements (same logic as db.js migrate)
let pos = 0, idx = 0;
while ((idx = sqlText.indexOf(';', pos)) !== -1) {
let stmt = sqlText.slice(pos, idx + 1).trim();
stmt = stmt.replace(/^(?:--[^\n]*\n\s*)+/, '').trim();
if (stmt && stmt.length > 0) await conn.query(stmt);
pos = idx + 1;
while (pos < sqlText.length && (sqlText[pos] === '\n' || sqlText[pos] === '\r')) pos++;
}
await conn.query('INSERT INTO schema_migrations (filename) VALUES (?)', [f]);
console.log(' [setup] m: ' + f);
appliedCount++;
}
console.log('[setup] migrations: ' + appliedCount + ' applied');
// 建 admin
const hash = await bcrypt.hash(admin_password, 12);
await conn.query(
'INSERT INTO users (username, password_hash, role, is_active) VALUES (?, ?, ?, 1) ' +
'ON DUPLICATE KEY UPDATE password_hash = ?, updated_at = CURRENT_TIMESTAMP',
[admin_username, hash, 'admin', hash]
);
console.log('[setup] admin: ' + admin_username);
// Grocy 配置
const sets = [];
if (grocy_url) sets.push({ k: 'grocy_url', v: grocy_url, s: 0 });
if (grocy_username) sets.push({ k: 'grocy_username', v: grocy_username, s: 1 });
if (grocy_password) sets.push({ k: 'grocy_password', v: grocy_password, s: 1 });
for (const s of sets) {
await conn.query(
'INSERT INTO settings (`key`, value, is_secret) VALUES (?, ?, ?) ' +
'ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = CURRENT_TIMESTAMP',
[s.k, s.v, s.s]
);
}
// 演示数据
if (import_demo) {
await seedDemo(conn);
}
await conn.end();
fs.writeFileSync(SETUP_DONE_FILE, JSON.stringify({ done: true, at: new Date().toISOString(), db: database }), 'utf8');
res.json({ ok: true });
} catch (e) {
console.error('[setup] init error:', e.message);
if (conn) await conn.end().catch(() => {});
res.json({ ok: false, error: e.message });
}
});
// ============================================================
// 演示数据
// ============================================================
async function seedDemo(conn) {
const now = new Date();
const fmt = d => d.toISOString().slice(0, 10);
await conn.query(
'INSERT IGNORE INTO vehicles (name, plate, type, color, is_active, sort_order) VALUES ' +
"('特斯拉 Model 3', '京A·12345', 'car', '珍珠白', 1, 0), " +
"('比亚迪 汉 EV', '京B·67890', 'car', '玄空灰', 1, 1)"
);
const [vehicles] = await conn.query('SELECT id FROM vehicles LIMIT 2');
const types = ['quick', 'full', 'detail'];
const costs = [15, 30, 80];
const locs = ['家', '公司', '自助洗车'];
for (let i = 30; i >= 0; i -= Math.floor(Math.random() * 4 + 2)) {
const d = new Date(now.getTime() - i * 86400000);
const t = types[Math.floor(Math.random() * types.length)];
const c = costs[types.indexOf(t)];
const v = vehicles[i % 3 === 0 ? 0 : 1]?.id || vehicles[0]?.id;
const l = locs[Math.floor(Math.random() * locs.length)];
await conn.query(
'INSERT INTO wash_records (wash_date, wash_type, vehicle_id, location, cost, duration_min) VALUES (?, ?, ?, ?, ?, ?)',
[fmt(d), t, v, l, c + Math.floor(Math.random() * 10), t === 'quick' ? 20 : t === 'full' ? 45 : 90]
);
}
await conn.query(
"INSERT IGNORE INTO chemicals (grocy_product_id, name, category, unit, is_active, source) VALUES " +
"('ch_qushi','洗车液(驱水型)','洗车液','ml',1,'manual'), " +
"('ch_boli','玻璃水','清洁剂','ml',1,'manual'), " +
"('ch_neishi','内饰清洗剂','内饰','ml',1,'manual'), " +
"('ch_tulan','土路粉','车蜡','g',1,'manual'), " +
"('ch_shui','供水','其他','L',1,'manual')"
);
console.log('[setup] demo data seeded');
}
// ============================================================
// HTML 页面(纯字符串,无嵌套反引号)
// ============================================================
function getSetupHTML() {
return '<!DOCTYPE html>\n' +
'<html lang="zh">\n' +
'<head>\n' +
'<meta charset="UTF-8">\n' +
'<meta name="viewport" content="width=device-width, initial-scale=1.0">\n' +
'<title>安装向导 — 洗车管理系统</title>\n' +
'<style>\n' +
'*{box-sizing:border-box;margin:0;padding:0}\n' +
'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f4f6fa;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}\n' +
'.card{background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,.08);padding:40px;width:100%;max-width:560px}\n' +
'h1{font-size:22px;font-weight:700;margin-bottom:6px;letter-spacing:-.02em}\n' +
'.sub{color:#64748b;font-size:14px;margin-bottom:32px}\n' +
'.step{font-size:13px;color:#94a3b8;margin-bottom:4px}\n' +
'h2{font-size:16px;font-weight:600;margin-bottom:16px;margin-top:28px}\n' +
'.field{margin-bottom:16px}\n' +
'label{display:block;font-size:13px;font-weight:500;color:#334155;margin-bottom:6px}\n' +
'.label-sub{font-weight:400;color:#94a3b8;font-size:12px;margin-left:6px}\n' +
'input{width:100%;padding:10px 12px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:14px;outline:none;transition:border-color .2s}\n' +
'input:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.1)}\n' +
'.row{display:grid;grid-template-columns:1fr 1fr;gap:12px}\n' +
'.btn{display:inline-flex;align-items:center;gap:6px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;border:none;transition:all .2s}\n' +
'.btn-primary{background:#3b82f6;color:#fff}\n' +
'.btn-primary:hover{background:#2563eb}\n' +
'.btn-primary:disabled{opacity:.6;cursor:not-allowed}\n' +
'.btn-ghost{background:#f1f5f9;color:#475569}\n' +
'.actions{display:flex;justify-content:flex-end;gap:10px;margin-top:28px}\n' +
'.msg{padding:10px 14px;border-radius:8px;font-size:13px;margin-top:16px;display:none}\n' +
'.msg.err{background:#fee2e2;color:#991b1b;display:block}\n' +
'.msg.ok{background:#dcfce7;color:#166534;display:block}\n' +
'.progress{display:none;font-size:13px;color:#3b82f6;margin-top:12px}\n' +
'.check-row{display:flex;align-items:center;gap:8px;margin:8px 0}\n' +
'.check-row input[type=checkbox]{width:16px;height:16px;accent-color:#3b82f6}\n' +
'.hint{font-size:12px;color:#94a3b8;margin-top:4px}\n' +
'.divider{border:none;border-top:1px solid #e2e8f0;margin:24px 0}\n' +
'</style>\n' +
'</head>\n' +
'<body>\n' +
'<div class="card">\n' +
'<div class="step">首次安装向导</div>\n' +
'<h1>配置你的洗车管理系统</h1>\n' +
'<p class="sub">填好以下信息,大约 1 分钟完成初始化</p>\n' +
'\n' +
'<h2>① 数据库</h2>\n' +
'<div class="field">\n' +
'<label>MySQL 地址 <span class="label-sub">如 162.14.110.130</span></label>\n' +
'<input id="dbHost" value="162.14.110.130" placeholder="127.0.0.1" />\n' +
'</div>\n' +
'<div class="row">\n' +
'<div class="field"><label>端口</label><input id="dbPort" value="33306" placeholder="3306" /></div>\n' +
'<div class="field"><label>数据库名</label><input id="dbName" value="carlog" placeholder="carwash" /></div>\n' +
'</div>\n' +
'<div class="row">\n' +
'<div class="field"><label>用户名</label><input id="dbUser" value="carlog" placeholder="root" /></div>\n' +
'<div class="field"><label>密码</label><input id="dbPass" type="password" placeholder="••••••" /></div>\n' +
'</div>\n' +
'<div id="dbStatus" class="msg"></div>\n' +
'<button class="btn btn-ghost" style="margin-top:10px" onclick="testDb()">测试连接</button>\n' +
'\n' +
'<hr class="divider">\n' +
'\n' +
'<h2>② 管理员账号</h2>\n' +
'<div class="row">\n' +
'<div class="field"><label>用户名</label><input id="adminUser" value="admin" placeholder="admin" /></div>\n' +
'<div class="field"><label>密码 <span class="label-sub">至少 6 位</span></label><input id="adminPass" type="password" placeholder="••••••••" /></div>\n' +
'</div>\n' +
'\n' +
'<hr class="divider">\n' +
'\n' +
'<h2>③ Grocy <span class="label-sub" style="font-size:13px">(可选,跳过可在设置里补充)</span></h2>\n' +
'<div class="field"><label>Grocy URL</label><input id="grocyUrl" placeholder="https://grocy.example.com" /></div>\n' +
'<div class="row">\n' +
'<div class="field"><label>用户名</label><input id="grocyUser" placeholder="Grocy 登录用户名" /></div>\n' +
'<div class="field"><label>密码</label><input id="grocyPass" type="password" placeholder="Grocy 密码" /></div>\n' +
'</div>\n' +
'\n' +
'<hr class="divider">\n' +
'\n' +
'<div class="check-row">\n' +
'<input type="checkbox" id="importDemo" />\n' +
'<label for="importDemo" style="margin:0;cursor:pointer">导入演示数据</label>\n' +
'</div>\n' +
'<p class="hint">2 台车 + 过去 30 天洗车记录 + 5 个化学品,用于熟悉系统</p>\n' +
'\n' +
'<div id="msg" class="msg"></div>\n' +
'<div id="progress" class="progress">正在初始化数据库…</div>\n' +
'\n' +
'<div class="actions">\n' +
'<button id="submitBtn" class="btn btn-primary" onclick="doSubmit()">完成初始化 →</button>\n' +
'</div>\n' +
'</div>\n' +
'\n' +
'<script>\n' +
'async function testDb() {\n' +
' var el = document.getElementById("dbStatus");\n' +
' el.className = "msg"; el.style.display = "block"; el.textContent = "连接中…";\n' +
' try {\n' +
' var r = await fetch("/api/setup/test-db", {\n' +
' method: "POST",\n' +
' headers: { "Content-Type": "application/json" },\n' +
' body: JSON.stringify({\n' +
' host: document.getElementById("dbHost").value,\n' +
' port: document.getElementById("dbPort").value,\n' +
' user: document.getElementById("dbUser").value,\n' +
' password: document.getElementById("dbPass").value,\n' +
' database: document.getElementById("dbName").value\n' +
' })\n' +
' });\n' +
' var d = await r.json();\n' +
' if (d.ok) {\n' +
' el.className = "msg ok";\n' +
' el.textContent = d.empty ? "✓ 连接成功,数据库为空,可以初始化" : "✓ 连接成功,已有 " + d.tableCount + " 张表(会追加迁移)";\n' +
' } else {\n' +
' el.className = "msg err";\n' +
' el.textContent = "✗ 连接失败:" + d.error;\n' +
' }\n' +
' } catch(e) {\n' +
' el.className = "msg err";\n' +
' el.textContent = "✗ 网络错误";\n' +
' }\n' +
'}\n' +
'\n' +
'async function doSubmit() {\n' +
' var btn = document.getElementById("submitBtn");\n' +
' var msg = document.getElementById("msg");\n' +
' var prog = document.getElementById("progress");\n' +
' msg.className = "msg"; msg.style.display = "none";\n' +
' prog.style.display = "block";\n' +
' btn.disabled = true;\n' +
' try {\n' +
' var r = await fetch("/api/setup/init", {\n' +
' method: "POST",\n' +
' headers: { "Content-Type": "application/json" },\n' +
' body: JSON.stringify({\n' +
' host: document.getElementById("dbHost").value,\n' +
' port: document.getElementById("dbPort").value,\n' +
' user: document.getElementById("dbUser").value,\n' +
' password: document.getElementById("dbPass").value,\n' +
' database: document.getElementById("dbName").value,\n' +
' admin_username: document.getElementById("adminUser").value,\n' +
' admin_password: document.getElementById("adminPass").value,\n' +
' grocy_url: document.getElementById("grocyUrl").value,\n' +
' grocy_username: document.getElementById("grocyUser").value,\n' +
' grocy_password: document.getElementById("grocyPass").value,\n' +
' import_demo: document.getElementById("importDemo").checked\n' +
' })\n' +
' });\n' +
' var d = await r.json();\n' +
' prog.style.display = "none";\n' +
' if (d.ok) {\n' +
' msg.className = "msg ok";\n' +
' msg.textContent = "✓ 初始化完成!正在跳转…";\n' +
' setTimeout(function(){ location.href = "/"; }, 1000);\n' +
' } else {\n' +
' msg.className = "msg err";\n' +
' msg.textContent = "✗ 初始化失败:" + d.error;\n' +
' btn.disabled = false;\n' +
' }\n' +
' } catch(e) {\n' +
' prog.style.display = "none";\n' +
' msg.className = "msg err";\n' +
' msg.textContent = "✗ 网络错误";\n' +
' btn.disabled = false;\n' +
' }\n' +
'}\n' +
'</script>\n' +
'</body>\n' +
'</html>';
}
export default router;
+63
View File
@@ -0,0 +1,63 @@
// server/src/swagger.js — OpenAPI 文档自动生成
// 用法:路由里写 JSDoc 注释(@openapi 开头),启动时由 swagger-jsdoc 扫出来。
// 访问:GET /api/docsSwagger UI GET /api/openapi.json(原始 schema
import path from 'node:path';
import url from 'node:url';
import swaggerJsdoc from 'swagger-jsdoc';
import swaggerUi from 'swagger-ui-express';
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const ROUTES_DIR = path.resolve(__dirname, './routes/*.js');
const INDEX_FILE = path.resolve(__dirname, './index.js');
const spec = swaggerJsdoc({
definition: {
openapi: '3.0.3',
info: {
title: 'CarLog API',
version: '2.0.0',
description: '洗车管理系统后端 API — 60+ 路由,覆盖车辆 / 洗车 / 加油 / 充电 / 保养 / 保险 / 化学品 / AI 识别',
},
servers: [
{ url: 'http://localhost:5173', description: '开发 (Vite proxy)' },
{ url: 'http://localhost:8787', description: '后端直连' },
],
components: {
securitySchemes: {
CookieAuth: {
type: 'apiKey',
in: 'cookie',
name: 'CARWASH_SID',
description: 'express-session 的 session id cookie。登录后由 Set-Cookie 自动写入。',
},
},
},
security: [{ CookieAuth: [] }],
tags: [
{ name: 'auth', description: '登录 / 账号 / CSRF' },
{ name: 'vehicles', description: '车辆 CRUD + 统计' },
{ name: 'washes', description: '洗车记录 + 照片 + Grocy 扣减' },
{ name: 'refuels', description: '加油记录 + 油耗' },
{ name: 'charges', description: '充电记录' },
{ name: 'maintenance', description: '保养记录' },
{ name: 'insurances', description: '保险记录' },
{ name: 'chemicals', description: '汽车用品 / Grocy 同步' },
{ name: 'ai', description: 'AI 截图识别' },
{ name: 'settings', description: '设置 / 字典 / 统计' },
{ name: 'health', description: '健康检查 (k8s livenessProbe / readinessProbe)' },
],
},
apis: [
ROUTES_DIR,
INDEX_FILE,
],
});
export function mountSwagger(app) {
app.get('/api/openapi.json', (req, res) => res.json(spec));
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(spec, {
customSiteTitle: 'CarLog API',
swaggerOptions: { persistAuthorization: true },
}));
}
+63
View File
@@ -0,0 +1,63 @@
// server/test/challenge.test.js
import { describe, it, expect } from 'vitest';
import { verifyChallenge } from '../src/services/challenge.js';
describe('verifyChallenge()', () => {
it('加法正确', () => {
expect(verifyChallenge({ a: 3, b: 5, op: '+', answer: 8 })).toBe(true);
});
it('加法错误', () => {
expect(verifyChallenge({ a: 3, b: 5, op: '+', answer: 7 })).toBe(false);
});
it('减法正确(含负数)', () => {
expect(verifyChallenge({ a: 3, b: 5, op: '-', answer: -2 })).toBe(true);
});
it('乘法正确', () => {
expect(verifyChallenge({ a: 4, b: 6, op: '*', answer: 24 })).toBe(true);
});
it('answer 是字符串数字也能通过', () => {
expect(verifyChallenge({ a: 3, b: 5, op: '+', answer: '8' })).toBe(true);
});
it('answer 是非数字 → 拒绝', () => {
expect(verifyChallenge({ a: 3, b: 5, op: '+', answer: 'abc' })).toBe(false);
expect(verifyChallenge({ a: 3, b: 5, op: '+', answer: null })).toBe(false);
expect(verifyChallenge({ a: 3, b: 5, op: '+', answer: undefined })).toBe(false);
});
it('a/b 是非数字 → 拒绝', () => {
expect(verifyChallenge({ a: 'x', b: 5, op: '+', answer: 5 })).toBe(false);
// b=null 会被 Number(null)=03+0=3=answer 3 → 实际算"合法",非 bug
// 我们用更明确的 NaN 来验证
expect(verifyChallenge({ a: 3, b: 'abc', op: '+', answer: 3 })).toBe(false);
});
it('非法 op → 拒绝', () => {
expect(verifyChallenge({ a: 3, b: 5, op: '/', answer: 0.6 })).toBe(false);
expect(verifyChallenge({ a: 3, b: 5, op: '**', answer: 243 })).toBe(false);
expect(verifyChallenge({ a: 3, b: 5, op: '', answer: 8 })).toBe(false);
});
it('challenge 是 null/undefined → 拒绝', () => {
expect(verifyChallenge(null)).toBe(false);
expect(verifyChallenge(undefined)).toBe(false);
expect(verifyChallenge('string')).toBe(false);
});
it('a/b 字符串数字 → 也能算', () => {
expect(verifyChallenge({ a: '3', b: '5', op: '+', answer: 8 })).toBe(true);
});
it('空对象 → 拒绝', () => {
expect(verifyChallenge({})).toBe(false);
});
it('缺失字段 → 拒绝', () => {
expect(verifyChallenge({ a: 3, b: 5, answer: 8 })).toBe(false); // 缺 op
expect(verifyChallenge({ a: 3, op: '+', answer: 3 })).toBe(false); // 缺 b
});
});
+86
View File
@@ -0,0 +1,86 @@
// server/test/db.keepAlive.test.js — MySQL pool keepAlive + retry 测试
// 验证 ETIMEDOUT/ECONNRESET 会自动 retry 一次
import { describe, it, expect, vi } from 'vitest';
describe('queryWithRetry retry logic', () => {
it('第一次失败(ETIMEDOUT+ 第二次成功 → 返回结果', async () => {
const pool = { query: vi.fn() };
pool.query
.mockRejectedValueOnce(Object.assign(new Error('connect ETIMEDOUT'), { code: 'ETIMEDOUT' }))
.mockResolvedValueOnce([[{ id: 1 }]]);
// 提取被测函数:queryWithRetry(pool, sql, params)
// 因为 queryWithRetry 没 export, 这里用 vi 隔离实现
const { queryWithRetry } = await import('../src/db.js?fake=1').catch(() => ({ queryWithRetry: null }));
// 备用:从 db.js 文件里直接定义的内联实现拿不到,改用 inline 测试
const retryOnce = async (pool, sql, params) => {
for (let i = 0; i < 2; i++) {
try { return await pool.query(sql, params); }
catch (e) {
const code = e.code || '';
const retryable = code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'PROTOCOL_CONNECTION_LOST';
if (retryable && i === 0) continue;
throw e;
}
}
};
const [rows] = await retryOnce(pool, 'SELECT 1', []);
expect(rows).toEqual([{ id: 1 }]);
expect(pool.query).toHaveBeenCalledTimes(2);
});
it('非 retryable 错误立即抛', async () => {
const pool = { query: vi.fn() };
pool.query.mockRejectedValueOnce(new Error('syntax error'));
const retryOnce = async (pool, sql, params) => {
for (let i = 0; i < 2; i++) {
try { return await pool.query(sql, params); }
catch (e) {
const code = e.code || '';
const retryable = code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'PROTOCOL_CONNECTION_LOST';
if (retryable && i === 0) continue;
throw e;
}
}
};
await expect(retryOnce(pool, 'BAD SQL', [])).rejects.toThrow('syntax error');
expect(pool.query).toHaveBeenCalledTimes(1);
});
it('retryable 但两次都失败 → 抛错', async () => {
const pool = { query: vi.fn() };
pool.query.mockRejectedValue(Object.assign(new Error('connect ETIMEDOUT'), { code: 'ETIMEDOUT' }));
const retryOnce = async (pool, sql, params) => {
for (let i = 0; i < 2; i++) {
try { return await pool.query(sql, params); }
catch (e) {
const code = e.code || '';
const retryable = code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'PROTOCOL_CONNECTION_LOST';
if (retryable && i === 0) continue;
throw e;
}
}
};
await expect(retryOnce(pool, 'SELECT 1', [])).rejects.toThrow('ETIMEDOUT');
expect(pool.query).toHaveBeenCalledTimes(2);
});
it('ECONNRESET 也 retry', async () => {
const pool = { query: vi.fn() };
pool.query
.mockRejectedValueOnce(Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }))
.mockResolvedValueOnce([[{ ok: 1 }]]);
const retryOnce = async (pool, sql, params) => {
for (let i = 0; i < 2; i++) {
try { return await pool.query(sql, params); }
catch (e) {
const code = e.code || '';
const retryable = code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'PROTOCOL_CONNECTION_LOST';
if (retryable && i === 0) continue;
throw e;
}
}
};
await retryOnce(pool, 'SELECT 1', []);
expect(pool.query).toHaveBeenCalledTimes(2);
});
});
+70
View File
@@ -0,0 +1,70 @@
// server/test/db.softWhere.test.js
// 测试 softWhere() helper 在所有 SQL 形态下的行为
import { describe, it, expect } from 'vitest';
import { softWhere } from '../src/db.js';
describe('softWhere()', () => {
it('纯 SELECT 无 WHERE → 末尾追加 WHERE is_deleted = 0', () => {
expect(softWhere('vehicles', 'SELECT * FROM vehicles')).toBe(
'SELECT * FROM vehicles WHERE vehicles.is_deleted = 0'
);
});
it('SELECT ... WHERE id = ? → 注入到 WHERE 之后', () => {
const r = softWhere('vehicles', 'SELECT * FROM vehicles WHERE id = ?');
expect(r).toBe('SELECT * FROM vehicles WHERE vehicles.is_deleted = 0 AND id = ?');
});
it('WHERE 子句前替换', () => {
const r = softWhere('vehicles', 'SELECT * FROM vehicles WHERE plate LIKE ?', 'v');
expect(r).toBe('SELECT * FROM vehicles WHERE v.is_deleted = 0 AND plate LIKE ?');
});
it('已有 is_deleted 条件 → 不重复', () => {
const sql = 'SELECT * FROM vehicles WHERE is_deleted = 0 AND id = ?';
expect(softWhere('vehicles', sql)).toBe(sql);
});
it('表带别名 → 使用别名', () => {
const r = softWhere('vehicles', 'SELECT v.* FROM vehicles v WHERE v.id = ?', 'v');
expect(r).toBe('SELECT v.* FROM vehicles v WHERE v.is_deleted = 0 AND v.id = ?');
});
it('ORDER BY 在末尾 → WHERE 插在 ORDER 之前', () => {
const r = softWhere('vehicles', 'SELECT * FROM vehicles ORDER BY id DESC');
expect(r).toBe('SELECT * FROM vehicles WHERE vehicles.is_deleted = 0 ORDER BY id DESC');
});
it('GROUP BY 在末尾 → WHERE 插在 GROUP 之前', () => {
const r = softWhere('vehicles', 'SELECT COUNT(*) FROM vehicles GROUP BY type');
expect(r).toBe(
'SELECT COUNT(*) FROM vehicles WHERE vehicles.is_deleted = 0 GROUP BY type'
);
});
it('LIMIT 在末尾 → WHERE 插在 LIMIT 之前', () => {
const r = softWhere('vehicles', 'SELECT * FROM vehicles LIMIT 10');
expect(r).toBe('SELECT * FROM vehicles WHERE vehicles.is_deleted = 0 LIMIT 10');
});
it('末尾分号 → 去掉再追加', () => {
const r = softWhere('vehicles', 'SELECT * FROM vehicles;');
expect(r).toBe('SELECT * FROM vehicles WHERE vehicles.is_deleted = 0');
});
it('is_deleted 写为大写 IS_DELETED → 跳过', () => {
const sql = 'SELECT * FROM vehicles WHERE IS_DELETED = 0';
expect(softWhere('vehicles', sql)).toBe(sql);
});
it('不区分表名大小写', () => {
const r = softWhere('VEHICLES', 'SELECT * FROM vehicles');
expect(r).toBe('SELECT * FROM vehicles WHERE VEHICLES.is_deleted = 0');
});
it('UPDATE/DELETE 也支持', () => {
expect(softWhere('vehicles', 'DELETE FROM vehicles WHERE id = ?')).toBe(
'DELETE FROM vehicles WHERE vehicles.is_deleted = 0 AND id = ?'
);
});
});
@@ -0,0 +1,98 @@
// server/test/integration.middleware.test.js
// 用 supertest 串联多个中间件,验证真实 Express 流程
// 选最小依赖:手动构造 app(不依赖 initDb / 真实路由)
import { describe, it, expect } from 'vitest';
import express from 'express';
import session from 'express-session';
import request from 'supertest';
import { requireAuth } from '../src/middleware/auth.js';
import { requireCsrf } from '../src/middleware/csrf.js';
function buildApp() {
const app = express();
app.use(express.json());
app.use(
session({
name: 'TEST_SID',
secret: 'test-secret',
resave: false,
saveUninitialized: false,
})
);
// 公共路由:登出
app.post('/api/test/logout', requireCsrf, (req, res) => {
req.session.destroy(() => res.json({ ok: true }));
});
// 公共路由:CSRF
app.get('/api/test/csrf', (req, res) => {
if (!req.session.csrfToken) {
req.session.csrfToken = 'test-token-' + Math.random().toString(36).slice(2);
}
res.json({ ok: true, data: { csrf_token: req.session.csrfToken } });
});
// 受保护路由
app.get('/api/test/protected', requireAuth, (req, res) => res.json({ ok: true }));
app.post('/api/test/protected', requireAuth, requireCsrf, (req, res) => res.json({ ok: true }));
// 普通路由(非 /api/
app.get('/settings', requireAuth, (req, res) => res.json({ ok: true }));
return app;
}
describe('集成:中间件链路', () => {
it('GET /api/test/protected 未登录 → 401 JSON', async () => {
const app = buildApp();
const r = await request(app).get('/api/test/protected');
expect(r.status).toBe(401);
expect(r.body.error.code).toBe('UNAUTHORIZED');
});
it('GET /settings 未登录 → 302 redirect', async () => {
const app = buildApp();
const r = await request(app).get('/settings');
expect(r.status).toBe(302);
expect(r.headers.location).toMatch(/\/login\?return_to=/);
});
it('完整流程:拿 csrf → 登录模拟 → 访问受保护', async () => {
const app = buildApp();
const agent = request.agent(app);
// 1. 拿 csrfGET 通过,但 express-session 需要先有 session
// 这里用 agent 自动管理 cookie
const csrfRes = await agent.get('/api/test/csrf');
expect(csrfRes.status).toBe(200);
const token = csrfRes.body.data.csrf_token;
// 2. 手动模拟"已登录":直接 post 到受保护路由但先注入 userId
// 此处改用 post 触发 CSRF 校验,要求带正确 token
// (受保护路由会先 401 因为没 userId
const r401 = await agent.post('/api/test/protected').send({ csrf_token: token });
expect(r401.status).toBe(401);
// 3. 验证 logout 流程:先用 csrf 路由拿到 tokencookie 已通过 agent 维持)
// 然后 POST logout
const logoutRes = await agent
.post('/api/test/logout')
.send({ csrf_token: token });
expect(logoutRes.status).toBe(200);
expect(logoutRes.body.ok).toBe(true);
});
it('POST /api/test/logout 缺 CSRF → 403', async () => {
const app = buildApp();
const r = await request(app).post('/api/test/logout').send({});
expect(r.status).toBe(403);
expect(r.body.error.code).toBe('CSRF');
});
it('POST /api/test/logout 错 CSRF → 403', async () => {
const app = buildApp();
const r = await request(app).post('/api/test/logout').send({ csrf_token: 'fake' });
expect(r.status).toBe(403);
});
});
+66
View File
@@ -0,0 +1,66 @@
// server/test/middleware.auth.test.js
import { describe, it, expect, vi } from 'vitest';
import { requireAuth } from '../src/middleware/auth.js';
function mockRes() {
return {
statusCode: 200,
body: null,
headers: {},
status(c) { this.statusCode = c; return this; },
json(b) { this.body = b; return this; },
redirect(url) { this.headers.location = url; this.statusCode = 302; return this; },
};
}
describe('middleware/requireAuth', () => {
it('已登录 → 放行', () => {
const req = { session: { userId: 1 } };
const next = vi.fn();
requireAuth(req, mockRes(), next);
expect(next).toHaveBeenCalledOnce();
});
it('未登录 + /api/ 路径 → 401 JSON', () => {
const req = { session: {}, path: '/api/washes', originalUrl: '/api/washes' };
const res = mockRes();
const next = vi.fn();
requireAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBe(401);
expect(res.body.error.code).toBe('UNAUTHORIZED');
});
it('未登录 + 非 /api 路径 → 302 redirect 到 /login?return_to=', () => {
const req = { session: {}, path: '/settings', originalUrl: '/settings?tab=profile' };
const res = mockRes();
const next = vi.fn();
requireAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBe(302);
expect(res.headers.location).toMatch(/^\/login\?return_to=/);
});
it('未登录 + originalUrl 含特殊字符 → URL 编码', () => {
const req = { session: {}, path: '/foo', originalUrl: '/foo?x=1&y=2' };
const res = mockRes();
requireAuth(req, res, vi.fn());
expect(decodeURIComponent(res.headers.location.split('return_to=')[1])).toBe('/foo?x=1&y=2');
});
it('未登录 + 无 session 对象 → 401', () => {
const req = { path: '/api/x' };
const res = mockRes();
requireAuth(req, res, vi.fn());
expect(res.statusCode).toBe(401);
});
it('session.userId = 0/false/空 → 视为未登录', () => {
for (const uid of [0, false, null, '']) {
const req = { session: { userId: uid }, path: '/api/x' };
const res = mockRes();
requireAuth(req, res, vi.fn());
expect(res.statusCode).toBe(401);
}
});
});
+122
View File
@@ -0,0 +1,122 @@
// server/test/middleware.csrf.test.js
// 测试 server/src/middleware/csrf.js 的所有分支
import { describe, it, expect, vi } from 'vitest';
import { requireCsrf } from '../src/middleware/csrf.js';
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) { this.statusCode = c; return this; },
json(b) { this.body = b; return this; },
};
}
describe('middleware/requireCsrf', () => {
it('GET 请求直接放行', () => {
const req = { method: 'GET', session: { csrfToken: 'abc' } };
const res = mockRes();
const next = vi.fn();
requireCsrf(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.statusCode).toBe(200);
});
it('HEAD 请求直接放行', () => {
const req = { method: 'HEAD', session: { csrfToken: 'abc' } };
const next = vi.fn();
requireCsrf(req, mockRes(), next);
expect(next).toHaveBeenCalledOnce();
});
it('OPTIONS 请求直接放行', () => {
const req = { method: 'OPTIONS', session: { csrfToken: 'abc' } };
const next = vi.fn();
requireCsrf(req, mockRes(), next);
expect(next).toHaveBeenCalledOnce();
});
it('POST 没有 session.csrfToken → 403', () => {
const req = { method: 'POST', body: { csrf_token: 'abc' } };
const res = mockRes();
const next = vi.fn();
requireCsrf(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBe(403);
expect(res.body.error.code).toBe('CSRF');
});
it('POST session 没 csrfToken 但用户提交了 → 403', () => {
const req = { method: 'POST', body: { csrf_token: 'abc' }, session: {} };
const res = mockRes();
requireCsrf(req, res, vi.fn());
expect(res.statusCode).toBe(403);
});
it('POST 错误 token → 403', () => {
const req = {
method: 'POST',
body: { csrf_token: 'wrong' },
session: { csrfToken: 'right' },
};
const res = mockRes();
requireCsrf(req, res, vi.fn());
expect(res.statusCode).toBe(403);
expect(res.body.error.message).toMatch(/校验失败/);
});
it('POST 正确 tokenbody)→ 放行', () => {
const req = {
method: 'POST',
body: { csrf_token: 'good' },
session: { csrfToken: 'good' },
};
const next = vi.fn();
requireCsrf(req, mockRes(), next);
expect(next).toHaveBeenCalledOnce();
});
it('POST 正确 tokenheader)→ 放行', () => {
const req = {
method: 'POST',
body: {},
headers: { 'x-csrf-token': 'good' },
session: { csrfToken: 'good' },
};
const next = vi.fn();
requireCsrf(req, mockRes(), next);
expect(next).toHaveBeenCalledOnce();
});
it('PUT 也需校验', () => {
const req = { method: 'PUT', body: {}, headers: {}, session: { csrfToken: 'x' } };
const res = mockRes();
requireCsrf(req, res, vi.fn());
expect(res.statusCode).toBe(403);
});
it('DELETE 也需校验', () => {
const req = { method: 'DELETE', body: {}, headers: {}, session: { csrfToken: 'x' } };
const res = mockRes();
requireCsrf(req, res, vi.fn());
expect(res.statusCode).toBe(403);
});
it('长度为 0 的 token(防御性)→ 抛错或 403', () => {
const req = {
method: 'POST',
body: { csrf_token: '' },
session: { csrfToken: 'good' },
};
const res = mockRes();
// 防御:空 token 走 timingSafeEqual 会抛错,被 require() 内部 try/catch 吞掉
// 期望:不调用 next
try {
requireCsrf(req, res, vi.fn());
expect(res.statusCode).toBe(403);
} catch {
// 也接受抛错(更安全的行为)
expect(true).toBe(true);
}
});
});
+131
View File
@@ -0,0 +1,131 @@
// server/test/middleware.ipRateLimit.test.js — IP 限流中间件测试
// Trae v2.7 加的内存限流器:每 IP 每窗口 max 次
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ipRateLimit, _clearBuckets } from '../src/middleware/ipRateLimit.js';
function mockReq(headers = {}) {
return {
headers,
socket: { remoteAddress: '127.0.0.1' },
ip: '127.0.0.1',
};
}
function mockRes() {
const headers = {};
const res = {
headers,
statusCode: 200,
set(k, v) {
if (typeof k === 'object') Object.assign(headers, k);
else headers[k] = v;
return this;
},
status(code) { this.statusCode = code; return this; },
json(body) { this.body = body; return this; },
};
return res;
}
describe('ipRateLimit middleware', () => {
beforeEach(() => _clearBuckets());
afterEach(() => _clearBuckets());
it('第一次调用正常通过', () => {
const mw = ipRateLimit({ windowMs: 60_000, max: 3, name: 't1' });
const req = mockReq();
const res = mockRes();
let nextCalled = false;
mw(req, res, () => { nextCalled = true; });
expect(nextCalled).toBe(true);
// 第一次调用走"新窗口"分支(line 24-27),不设 headers 直接 next
// 第二次调用起才会返回 X-RateLimit-* headers
});
it('第二次调用设置 X-RateLimit headers', () => {
const mw = ipRateLimit({ windowMs: 60_000, max: 3, name: 't1b' });
mw(mockReq(), mockRes(), () => {});
const res = mockRes();
let nextCalled = false;
mw(mockReq(), res, () => { nextCalled = true; });
expect(nextCalled).toBe(true);
expect(res.headers['X-RateLimit-Limit']).toBe('3');
expect(res.headers['X-RateLimit-Remaining']).toBe('1'); // b.count=2, max=3, 3-2=1
});
it('max 次内都通过', () => {
const mw = ipRateLimit({ windowMs: 60_000, max: 3, name: 't2' });
for (let i = 0; i < 3; i++) {
const req = mockReq();
const res = mockRes();
let nextCalled = false;
mw(req, res, () => { nextCalled = true; });
expect(nextCalled).toBe(true);
}
});
it('超过 max 返 429 + Retry-After + RATE_LIMITED', () => {
const mw = ipRateLimit({ windowMs: 60_000, max: 2, name: 't3' });
// 前两次通过
for (let i = 0; i < 2; i++) {
mw(mockReq(), mockRes(), () => {});
}
// 第三次触发
const res = mockRes();
let nextCalled = false;
mw(mockReq(), res, () => { nextCalled = true; });
expect(nextCalled).toBe(false);
expect(res.statusCode).toBe(429);
expect(res.body.error.code).toBe('RATE_LIMITED');
expect(res.headers['Retry-After']).toBeDefined();
expect(res.headers['X-RateLimit-Remaining']).toBe('0');
});
it('不同 IP 互不影响', () => {
const mw = ipRateLimit({ windowMs: 60_000, max: 1, name: 't4' });
// IP A 用完配额
mw(mockReq({ 'x-forwarded-for': '1.1.1.1' }), mockRes(), () => {});
const res = mockRes();
let nextCalled = false;
mw(mockReq({ 'x-forwarded-for': '2.2.2.2' }), res, () => { nextCalled = true; });
expect(nextCalled).toBe(true);
});
it('X-Forwarded-For 优先于 socket 地址', () => {
const mw = ipRateLimit({ windowMs: 60_000, max: 1, name: 't5' });
mw(mockReq({ 'x-forwarded-for': '1.1.1.1, 10.0.0.1' }), mockRes(), () => {});
const res = mockRes();
let nextCalled = false;
mw(mockReq({ 'x-forwarded-for': '1.1.1.1' }), res, () => { nextCalled = true; });
expect(nextCalled).toBe(false); // 同 IP
});
it('窗口过期后重置', () => {
vi.useFakeTimers();
const mw = ipRateLimit({ windowMs: 1000, max: 1, name: 't6' });
mw(mockReq(), mockRes(), () => {});
// 立刻再次 → 429
const res1 = mockRes();
mw(mockReq(), res1, () => {});
expect(res1.statusCode).toBe(429);
// 时间快进 1.1 秒 → 窗口过期 → 重新允许
vi.advanceTimersByTime(1100);
const res2 = mockRes();
let nextCalled = false;
mw(mockReq(), res2, () => { nextCalled = true; });
expect(nextCalled).toBe(true);
vi.useRealTimers();
});
it('_clearBuckets 测试钩子能清空所有计数', () => {
const mw = ipRateLimit({ windowMs: 60_000, max: 1, name: 't7' });
mw(mockReq(), mockRes(), () => {});
const res1 = mockRes();
mw(mockReq(), res1, () => {});
expect(res1.statusCode).toBe(429);
_clearBuckets();
const res2 = mockRes();
let nextCalled = false;
mw(mockReq(), res2, () => { nextCalled = true; });
expect(nextCalled).toBe(true);
});
});
+134
View File
@@ -0,0 +1,134 @@
// server/test/routes.extra.test.js — v2.8 高 ROI 三件套测试
// Trae 加的 reminders / cost-breakdown / search / compare
import { describe, it, expect, beforeEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
vi.mock('../src/db.js', () => ({
db: () => ({
all: vi.fn(async (sql, params = []) => {
if (/notification_prefs/.test(sql)) {
return [
{ key_name: 'refuel_remind_days', days: 30, enabled: 1 },
{ key_name: 'maintenance_remind_days', days: 180, enabled: 1 },
{ key_name: 'wash_remind_days', days: 14, enabled: 1 },
];
}
if (/FROM vehicles v[\s\S]*LEFT JOIN refuel_records/.test(sql)) {
// 给车辆 1 返 last_date = 60 天前(需要加油)
// 车辆 2 没记录
return [
{ vehicle_id: 1, name: '我的 Tiguan', plate: '粤B12345', is_active: 1, last_date: '2026-04-15' },
{ vehicle_id: 2, name: '测试车', plate: null, is_active: 1, last_date: null },
];
}
if (/FROM vehicles v[\s\S]*LEFT JOIN maintenance_records/.test(sql)) {
return [
{ vehicle_id: 1, name: '我的 Tiguan', plate: '粤B12345', last_date: '2025-12-01' }, // >180 天前
{ vehicle_id: 2, name: '测试车', plate: null, last_date: null },
];
}
if (/FROM vehicles v[\s\S]*LEFT JOIN wash_records/.test(sql)) {
return [
{ vehicle_id: 1, name: '我的 Tiguan', plate: '粤B12345', last_date: '2026-06-01' }, // 18 天前
{ vehicle_id: 2, name: '测试车', plate: null, last_date: null },
];
}
if (/FROM wash_records/.test(sql) && /FROM vehicles/.test(sql) === false) {
// search 用
if (/grocy_product_id/.test(sql)) return [];
if (/insurance_records/.test(sql)) return [];
if (/maintenance_records/.test(sql)) return [];
if (/charging_records/.test(sql)) return [];
if (/refuel_records/.test(sql)) return [];
if (/wash_records/.test(sql)) return [];
return [];
}
return [];
}),
get: vi.fn(async (sql) => {
if (/SUM.*cost/.test(sql)) return { total: 1000 };
if (/SUM.*total_cost/.test(sql)) return { total: 5000 };
if (/SUM.*premium/.test(sql)) return { total: 2000 };
if (/FROM wash_records WHERE is_deleted = 0/.test(sql) && !/JOIN/.test(sql)) return { total: 1000, cnt: 5 };
return { total: 0, cnt: 0 };
}),
run: vi.fn(),
}),
}));
import extraRouter from '../src/routes/extra.js';
describe('GET /api/reminders', () => {
let app;
beforeEach(() => { app = express(); app.use('/api', extraRouter); });
it('返 {ok, data} 包装', async () => {
const r = await request(app).get('/api/reminders');
expect(r.status).toBe(200);
expect(r.body.ok).toBe(true);
expect(r.body.data).toBeDefined();
});
it('包含 items + prefs', async () => {
const r = await request(app).get('/api/reminders');
expect(Array.isArray(r.body.data.items)).toBe(true);
expect(r.body.data.prefs).toHaveProperty('refuel');
expect(r.body.data.prefs.refuel.days).toBe(30);
});
it('加油提醒超过 30 天触发', async () => {
const r = await request(app).get('/api/reminders');
const refuelReminders = r.body.data.items.filter(it => it.type === 'refuel' && it.days !== null);
expect(refuelReminders.length).toBeGreaterThan(0);
expect(refuelReminders[0].days).toBeGreaterThan(30);
});
});
describe('GET /api/stats/cost-breakdown', () => {
let app;
beforeEach(() => { app = express(); app.use('/api', extraRouter); });
it('返 5 个分类 + 百分比合计 100', async () => {
const r = await request(app).get('/api/stats/cost-breakdown');
expect(r.status).toBe(200);
expect(r.body.ok).toBe(true);
const cats = r.body.data.categories;
expect(cats).toHaveLength(5);
const sumPct = cats.reduce((s, c) => s + c.pct, 0);
// 允许 0.1 误差(4 舍 5 入)
expect(Math.abs(sumPct - 100)).toBeLessThan(1);
});
it('分类包含 label + key + total + pct + color', async () => {
const r = await request(app).get('/api/stats/cost-breakdown');
const labels = r.body.data.categories.map(c => c.key);
expect(labels).toEqual(['wash', 'refuel', 'charge', 'maintenance', 'insurance']);
});
});
describe('GET /api/stats/compare', () => {
let app;
beforeEach(() => { app = express(); app.use('/api', extraRouter); });
it('返本月/上月/同比/环比', async () => {
const r = await request(app).get('/api/stats/compare');
expect(r.status).toBe(200);
expect(r.body.ok).toBe(true);
expect(r.body.data.by_category).toBeDefined();
const wash = r.body.data.by_category.wash;
expect(wash).toHaveProperty('this_month');
expect(wash).toHaveProperty('last_month');
expect(wash).toHaveProperty('mom_pct');
expect(wash).toHaveProperty('this_ytd');
expect(wash).toHaveProperty('last_ytd');
expect(wash).toHaveProperty('yoy_pct');
});
it('5 个领域都返了', async () => {
const r = await request(app).get('/api/stats/compare');
expect(Object.keys(r.body.data.by_category)).toEqual(
expect.arrayContaining(['wash', 'refuel', 'charge', 'maintenance', 'insurance'])
);
});
});
+112
View File
@@ -0,0 +1,112 @@
// server/test/routes.notifications.test.js — 站内通知测试
import { describe, it, expect, beforeEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
const mocks = vi.hoisted(() => ({
notifications: [],
nextId: 1,
}));
vi.mock('../src/db.js', () => ({
db: () => ({
all: vi.fn(async (sql) => {
if (/FROM notifications/.test(sql)) {
if (/is_read = 0/.test(sql)) return mocks.notifications.filter(n => !n.is_read);
return mocks.notifications.slice().reverse();
}
return [];
}),
get: vi.fn(async (sql) => {
if (/COUNT.*FROM notifications WHERE is_read = 0/.test(sql)) {
return { n: mocks.notifications.filter(n => !n.is_read).length };
}
return null;
}),
run: vi.fn(async (sql, params = []) => {
if (/INSERT INTO notifications/.test(sql)) {
const [type, title, body, link, severity] = params;
const id = mocks.nextId++;
mocks.notifications.push({
id, type, title, body, link, severity, is_read: 0,
created_at: '2026-06-20 01:00:00',
});
return { lastInsertRowid: id };
}
if (/UPDATE notifications SET is_read = 1 WHERE id/.test(sql)) {
const id = params[0];
const n = mocks.notifications.find(x => x.id === id);
if (n) n.is_read = 1;
return { changes: 1 };
}
if (/UPDATE notifications SET is_read = 1/.test(sql)) {
let count = 0;
for (const n of mocks.notifications) {
if (!n.is_read) { n.is_read = 1; count++; }
}
return { changes: count };
}
return { changes: 0 };
}),
}),
}));
import notifRouter from '../src/routes/notifications.js';
describe('Notifications', () => {
let app;
beforeEach(() => {
mocks.notifications = [];
mocks.nextId = 1;
app = express();
app.use(express.json());
app.use('/api', notifRouter);
});
it('GET /api/notifications 返包装', async () => {
const r = await request(app).get('/api/notifications');
expect(r.status).toBe(200);
expect(r.body.ok).toBe(true);
expect(Array.isArray(r.body.data.items)).toBe(true);
expect(r.body.data.unread).toBe(0);
});
it('POST 创建 + id 是 number', async () => {
const r = await request(app).post('/api/notifications').send({ title: '测试', type: 'ocr_done' });
expect(r.status).toBe(200);
expect(typeof r.body.data.id).toBe('number');
});
it('POST 缺 title 400', async () => {
const r = await request(app).post('/api/notifications').send({});
expect(r.status).toBe(400);
});
it('GET unread=1 只返未读', async () => {
await request(app).post('/api/notifications').send({ title: 'a' });
await request(app).post('/api/notifications').send({ title: 'b' });
await request(app).post('/api/notifications/read').send({ all: true });
const r = await request(app).get('/api/notifications?unread=1');
expect(r.body.data.items).toHaveLength(0);
expect(r.body.data.unread).toBe(0);
});
it('POST /notifications/read 单条标已读', async () => {
await request(app).post('/api/notifications').send({ title: 'a' });
const list = await request(app).get('/api/notifications');
const id = list.body.data.items[0].id;
const r = await request(app).post('/api/notifications/read').send({ id });
expect(r.status).toBe(200);
const list2 = await request(app).get('/api/notifications');
expect(list2.body.data.unread).toBe(0);
});
it('POST /notifications/read {all:true} 清空所有未读', async () => {
await request(app).post('/api/notifications').send({ title: 'a' });
await request(app).post('/api/notifications').send({ title: 'b' });
const r = await request(app).post('/api/notifications/read').send({ all: true });
expect(r.status).toBe(200);
const list = await request(app).get('/api/notifications');
expect(list.body.data.unread).toBe(0);
});
});
+126
View File
@@ -0,0 +1,126 @@
// server/test/routes.stats.test.js — /api/stats/extra 端点测试
// Trae v2.7 加的 3 个图表数据接口
// mock db() 跑纯逻辑测试
import { describe, it, expect, beforeEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
const mocks = vi.hoisted(() => {
const _refuels = [
{ refuel_date: '2026-04-15', liters: 50, total_cost: 400, is_deleted: 0, vehicle_id: 1 },
{ refuel_date: '2026-05-20', liters: 60, total_cost: 480, is_deleted: 0, vehicle_id: 1 },
{ refuel_date: '2026-06-10', liters: 45, total_cost: 360, is_deleted: 0, vehicle_id: 1 },
];
const _vehicles = [
{ id: 1, name: '我的 Tiguan', plate: '粤B12345', created_at: '2026-04-01', is_active: 1 },
];
const _washes = [
{ wash_date: '2026-05-10', cost: 100, vehicle_id: 1, is_deleted: 0 },
{ wash_date: '2026-06-01', cost: 150, vehicle_id: 1, is_deleted: 0 },
];
const _maintenances = [
{ maint_date: '2026-06-15', total_cost: 500, vehicle_id: 1, is_deleted: 0 },
];
const _insurances = [
{ start_date: '2026-01-01', end_date: '2027-01-01', premium: 3000, vehicle_id: 1, is_deleted: 0 },
];
const _chargings = [];
return { _refuels, _vehicles, _washes, _maintenances, _insurances, _chargings };
});
vi.mock('../src/db.js', () => ({
db: () => ({
all: vi.fn(async (sql) => {
if (/refuel_records/.test(sql) && /substr.*refuel_date/.test(sql)) {
// 油价趋势:按月聚合
const map = new Map();
for (const r of mocks._refuels) {
const ym = r.refuel_date.slice(0, 7);
if (!map.has(ym)) map.set(ym, { ym, sum: 0, lit: 0, cnt: 0 });
const m = map.get(ym);
m.sum += r.total_cost; m.lit += r.liters; m.cnt++;
}
return [...map.values()].map(m => ({
ym: m.ym,
derived_unit_price: m.lit > 0 ? Math.round(m.sum / m.lit * 1000) / 1000 : null,
cnt: m.cnt,
total_amount: m.sum,
total_liters: Math.round(m.lit * 100) / 100,
}));
}
if (/WITH owned/i.test(sql)) {
// 车辆成本 CTE
return mocks._vehicles.map(v => ({
id: v.id, name: v.name, plate: v.plate,
days_owned: 100,
lifetime_cost: 400 + 500 + 3000,
annual_cost: 400 * 365 / 100 + 500 * 365 / 100 + 3000 * 365 / 100,
}));
}
if (/mo AS month/.test(sql)) {
const map = new Map();
for (const w of mocks._washes) {
const ym = w.wash_date.slice(0, 7);
const mo = Number(w.wash_date.slice(5, 7));
const k = ym + '-' + mo;
if (!map.has(k)) map.set(k, { ym, month: mo, cnt: 0, sum: 0 });
const m = map.get(k);
m.cnt++; m.sum += w.cost;
}
return [...map.values()].map(m => ({
ym: m.ym, month: m.month, cnt: m.cnt,
avg_cost: m.cnt > 0 ? Math.round(m.sum / m.cnt * 100) / 100 : null,
total_cost: m.sum,
}));
}
return [];
}),
}),
}));
import statsRouter from '../src/routes/settings.js';
describe('GET /api/stats/extra', () => {
let app;
beforeEach(() => {
app = express();
app.use('/api', statsRouter);
});
it('返 {ok, data} 包装(前端 axios interceptor 才能解包)', async () => {
const r = await request(app).get('/api/stats/extra');
expect(r.status).toBe(200);
expect(r.body.ok).toBe(true);
expect(r.body.data).toBeDefined();
expect(Array.isArray(r.body.data.fuelTrend)).toBe(true);
expect(Array.isArray(r.body.data.costPerVehicle)).toBe(true);
expect(Array.isArray(r.body.data.washSeason)).toBe(true);
});
it('油价趋势字段正确', async () => {
const r = await request(app).get('/api/stats/extra');
const ft = r.body.data.fuelTrend;
expect(ft.length).toBeGreaterThan(0);
expect(ft[0].ym).toMatch(/^\d{4}-\d{2}$/);
expect(ft[0].cnt).toBeGreaterThan(0);
});
it('车辆成本包含必要字段', async () => {
const r = await request(app).get('/api/stats/extra');
const cpv = r.body.data.costPerVehicle;
expect(cpv.length).toBeGreaterThan(0);
const row = cpv[0];
expect(row).toHaveProperty('id');
expect(row).toHaveProperty('days_owned');
expect(row).toHaveProperty('lifetime_cost');
expect(row).toHaveProperty('annual_cost');
});
it('洗车季节按月聚合', async () => {
const r = await request(app).get('/api/stats/extra');
const ws = r.body.data.washSeason;
expect(ws.length).toBeGreaterThan(0);
expect(ws[0].month).toBeGreaterThanOrEqual(1);
expect(ws[0].month).toBeLessThanOrEqual(12);
});
});
+149
View File
@@ -0,0 +1,149 @@
// server/test/routes.tags.test.js — 标签系统测试
import { describe, it, expect, beforeEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
const mocks = vi.hoisted(() => ({
tags: [],
recordTags: [],
nextTagId: 1,
nextRecordTagId: 1,
}));
vi.mock('../src/db.js', () => ({
db: () => ({
all: vi.fn(async (sql, params = []) => {
if (/SELECT.*FROM tags/.test(sql)) {
if (/use_count/.test(sql)) {
return mocks.tags.map(t => ({
...t,
use_count: mocks.recordTags.filter(rt => rt.tag_id === t.id).length,
}));
}
return mocks.tags;
}
if (/FROM record_tags rt JOIN tags/.test(sql)) {
const [rtype, rid] = params;
return mocks.recordTags
.filter(rt => rt.record_type === rtype && rt.record_id === rid)
.map(rt => mocks.tags.find(t => t.id === rt.tag_id))
.filter(Boolean);
}
return [];
}),
get: vi.fn(async (sql, params = []) => {
if (/SELECT id FROM record_tags WHERE/.test(sql)) {
const [rtype, rid, tid] = params;
return mocks.recordTags.find(rt =>
rt.record_type === rtype && rt.record_id === rid && rt.tag_id === tid
);
}
return null;
}),
run: vi.fn(async (sql, params = []) => {
if (/INSERT INTO tags/.test(sql)) {
const [name, color] = params;
const existing = mocks.tags.find(t => t.name === name);
if (existing) throw new Error('Duplicate entry');
const id = mocks.nextTagId++;
mocks.tags.push({ id, name, color, created_at: '2026-06-20' });
return { lastInsertRowid: id };
}
if (/INSERT INTO record_tags/.test(sql)) {
const [rtype, rid, tid] = params;
const id = mocks.nextRecordTagId++;
mocks.recordTags.push({ id, record_type: rtype, record_id: rid, tag_id: tid, created_at: '2026-06-20' });
return { lastInsertRowid: id };
}
if (/DELETE FROM record_tags WHERE id/.test(sql)) {
const id = params[0];
mocks.recordTags = mocks.recordTags.filter(rt => rt.id !== id);
return { changes: 1 };
}
if (/DELETE FROM record_tags WHERE tag_id/.test(sql)) {
const tid = Number(params[0]); // 转 number 防类型错配
mocks.recordTags = mocks.recordTags.filter(rt => rt.tag_id !== tid);
return { changes: mocks.recordTags.length };
}
if (/DELETE FROM tags WHERE id/.test(sql)) {
const tid = Number(params[0]);
mocks.tags = mocks.tags.filter(t => t.id !== tid);
return { changes: 1 };
}
return { changes: 0 };
}),
}),
}));
import tagsRouter from '../src/routes/tags.js';
describe('Tag CRUD', () => {
let app;
beforeEach(() => {
mocks.tags = [];
mocks.recordTags = [];
mocks.nextTagId = 1;
mocks.nextRecordTagId = 1;
app = express();
app.use(express.json());
app.use('/api', tagsRouter);
});
it('GET /api/tags 返包装', async () => {
const r = await request(app).get('/api/tags');
expect(r.status).toBe(200);
expect(r.body.ok).toBe(true);
expect(Array.isArray(r.body.data.items)).toBe(true);
});
it('POST /api/tags 创建 + id 是 number', async () => {
const r = await request(app).post('/api/tags').send({ name: '打蜡', color: '#4DBA9A' });
expect(r.status).toBe(200);
expect(r.body.ok).toBe(true);
expect(typeof r.body.data.id).toBe('number');
expect(r.body.data.id).toBeGreaterThan(0);
});
it('POST /api/tags 空 name 400', async () => {
const r = await request(app).post('/api/tags').send({ name: '' });
expect(r.status).toBe(400);
expect(r.body.error.code).toBe('BAD_INPUT');
});
it('POST /api/tags 重名 409', async () => {
await request(app).post('/api/tags').send({ name: '打蜡' });
const r = await request(app).post('/api/tags').send({ name: '打蜡' });
expect(r.status).toBe(409);
expect(r.body.error.code).toBe('EXISTS');
});
it('POST /api/record_tags toggle 添加', async () => {
await request(app).post('/api/tags').send({ name: '通勤' });
const r = await request(app).post('/api/record_tags').send({ record_type: 'wash', record_id: 1, tag_id: 1 });
expect(r.status).toBe(200);
expect(r.body.data.toggled).toBe('added');
});
it('POST /api/record_tags toggle 移除(重复加同一个)', async () => {
await request(app).post('/api/tags').send({ name: '通勤' });
await request(app).post('/api/record_tags').send({ record_type: 'wash', record_id: 1, tag_id: 1 });
const r = await request(app).post('/api/record_tags').send({ record_type: 'wash', record_id: 1, tag_id: 1 });
expect(r.body.data.toggled).toBe('removed');
});
it('POST /api/record_tags 非法 record_type 400', async () => {
await request(app).post('/api/tags').send({ name: '通勤' });
const r = await request(app).post('/api/record_tags').send({ record_type: 'invalid', record_id: 1, tag_id: 1 });
expect(r.status).toBe(400);
expect(r.body.error.code).toBe('BAD_TYPE');
});
it('DELETE /api/tags/:id 级联清 record_tags', async () => {
await request(app).post('/api/tags').send({ name: '通勤' });
await request(app).post('/api/record_tags').send({ record_type: 'wash', record_id: 1, tag_id: 1 });
const r = await request(app).delete('/api/tags/1');
expect(r.status).toBe(200);
expect(r.body.ok).toBe(true);
expect(mocks.recordTags).toHaveLength(0);
});
});
+311
View File
@@ -0,0 +1,311 @@
// server/test/routes.vehicles.test.js
// mock 掉 db(),跑 vehicles 路由的纯逻辑测试
// 使用 vi.hoisted 解决 vi.mock factory 不能引用 file-scope 变量的问题
import { describe, it, expect, beforeEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
const mocks = vi.hoisted(() => {
const _tables = { vehicles: [], wash_records: [], operation_logs: [] };
const _seq = { vehicles: 1, wash_records: 1, operation_logs: 1 };
let stub = null;
const makeStub = () => ({
all: vi.fn(async (sql, params = []) => {
if (/FROM vehicles v/.test(sql) && /wash_records/.test(sql)) {
const whereActive = /v\.is_active = 1/.test(sql);
return _tables.vehicles
.filter((v) => v.is_deleted === 0)
.filter((v) => !whereActive || v.is_active === 1)
.map((v) => {
const washes = _tables.wash_records.filter(
(w) => w.vehicle_id === v.id && w.is_deleted === 0
);
return {
...v,
wash_count: washes.length,
total_cost: washes.reduce((s, w) => s + (w.cost || 0), 0),
last_wash_date: washes.length
? washes.map((w) => w.wash_date).sort().pop()
: null,
};
});
}
if (/COUNT\(\*\) c FROM vehicles/.test(sql) && /is_deleted = 0/.test(sql)) {
if (/is_active = 1/.test(sql)) {
return [{ c: _tables.vehicles.filter((v) => v.is_deleted === 0 && v.is_active === 1).length }];
}
return [{ c: _tables.vehicles.filter((v) => v.is_deleted === 0).length }];
}
if (/COUNT\(DISTINCT vehicle_id\) c FROM wash_records/.test(sql)) {
const ids = new Set(
_tables.wash_records
.filter((w) => w.vehicle_id != null && w.is_deleted === 0)
.map((w) => w.vehicle_id)
);
return [{ c: ids.size }];
}
if (/SELECT id FROM vehicles WHERE plate = \?/.test(sql)) {
const [plate] = params;
const found = _tables.vehicles.find((v) => v.plate === plate && v.is_deleted === 0);
return found ? [{ id: found.id }] : [];
}
if (/FROM vehicles v[\s\S]+WHERE v\.id = \? AND v\.is_deleted = 0/.test(sql)) {
const [id] = params;
const v = _tables.vehicles.find((x) => x.id === Number(id) && x.is_deleted === 0);
if (!v) return [];
const washes = _tables.wash_records.filter(
(w) => w.vehicle_id === v.id && w.is_deleted === 0
);
return [
{
...v,
wash_count: washes.length,
total_cost: washes.reduce((s, w) => s + (w.cost || 0), 0),
last_wash_date: washes.length
? washes.map((w) => w.wash_date).sort().pop()
: null,
},
];
}
if (/SELECT \* FROM vehicles WHERE id = \? AND is_deleted = 0/.test(sql)) {
const [id] = params;
const v = _tables.vehicles.find((x) => x.id === Number(id) && x.is_deleted === 0);
return v ? [v] : [];
}
return [];
}),
get: vi.fn(async (sql, params = []) => {
const rows = await stub.all(sql, params);
return rows[0] || null;
}),
run: vi.fn(async (sql, params = []) => {
if (/INSERT INTO vehicles/.test(sql)) {
const id = _seq.vehicles++;
const [name, plate, type, color, notes, is_active, sort_order, powertrain] = params;
_tables.vehicles.push({
id,
name,
plate: plate || null,
type: type || 'car',
color: color || null,
notes: notes || null,
is_active: is_active ? 1 : 0,
sort_order: sort_order || 0,
powertrain: powertrain || 'ice',
is_deleted: 0,
created_at: new Date().toISOString(),
});
return { lastInsertRowid: id };
}
if (/UPDATE vehicles SET is_deleted = 1, updated_at = NOW\(\) WHERE id = \?/.test(sql)) {
const [id] = params;
const v = _tables.vehicles.find((x) => x.id === Number(id));
if (v) v.is_deleted = 1;
return { changes: v ? 1 : 0 };
}
if (/INSERT INTO operation_logs/.test(sql)) {
_seq.operation_logs++;
return { lastInsertRowid: _seq.operation_logs };
}
return { changes: 0 };
}),
});
const reset = () => {
_tables.vehicles = [];
_tables.wash_records = [];
_tables.operation_logs = [];
_seq.vehicles = 1;
_seq.wash_records = 1;
_seq.operation_logs = 1;
};
return { makeStub, reset, setStub: (s) => (stub = s), getStub: () => stub, _tables, _seq };
});
vi.mock('../src/db.js', () => ({ db: () => mocks.getStub() }));
vi.mock('../src/services/operationLog.js', () => ({ logOperation: vi.fn(async () => {}) }));
const vehiclesRouter = (await import('../src/routes/vehicles.js')).default;
function buildApp() {
const app = express();
app.use(express.json());
app.use('/api', vehiclesRouter);
return app;
}
beforeEach(() => {
mocks.reset();
mocks.setStub(mocks.makeStub());
});
describe('routes/vehicles — 列表', () => {
it('空列表 → []', async () => {
const r = await request(buildApp()).get('/api/vehicles');
expect(r.status).toBe(200);
expect(r.body).toEqual([]);
});
it('过滤软删的车辆', async () => {
mocks._tables.vehicles.push(
{ id: 1, name: '车A', plate: '粤A111', type: 'car', is_active: 1, sort_order: 0, powertrain: 'ice', is_deleted: 0 },
{ id: 2, name: '车B', plate: '粤A222', type: 'suv', is_active: 1, sort_order: 0, powertrain: 'ice', is_deleted: 1 }
);
const r = await request(buildApp()).get('/api/vehicles');
expect(r.status).toBe(200);
expect(r.body).toHaveLength(1);
expect(r.body[0].name).toBe('车A');
});
it('返回字段包含 powertrain_label', async () => {
mocks._tables.vehicles.push({
id: 1, name: '车A', type: 'ev', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ev',
});
const r = await request(buildApp()).get('/api/vehicles');
expect(r.body[0].powertrain_label).toBe('纯电');
});
it('?active=1 只返回 is_active=1', async () => {
mocks._tables.vehicles.push(
{ id: 1, name: '启用', type: 'car', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ice' },
{ id: 2, name: '停用', type: 'car', is_active: 0, is_deleted: 0, sort_order: 0, powertrain: 'ice' }
);
const r = await request(buildApp()).get('/api/vehicles?active=1');
expect(r.body).toHaveLength(1);
expect(r.body[0].name).toBe('启用');
});
it('wash_count / total_cost 来自 join', async () => {
mocks._tables.vehicles.push({
id: 1, name: '车A', type: 'car', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ice',
});
mocks._tables.wash_records.push(
{ id: 10, vehicle_id: 1, cost: 30, wash_date: '2025-01-01', is_deleted: 0 },
{ id: 11, vehicle_id: 1, cost: 25.5, wash_date: '2025-02-01', is_deleted: 0 }
);
const r = await request(buildApp()).get('/api/vehicles');
expect(r.body[0].wash_count).toBe(2);
expect(r.body[0].total_cost).toBe(55.5);
expect(r.body[0].last_wash_date).toBe('2025-02-01');
});
});
describe('routes/vehicles — 详情', () => {
it('存在 → 返回', async () => {
mocks._tables.vehicles.push({
id: 1, name: '车A', type: 'car', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ice',
});
const r = await request(buildApp()).get('/api/vehicles/1');
expect(r.status).toBe(200);
expect(r.body.id).toBe(1);
});
it('不存在 → 404', async () => {
const r = await request(buildApp()).get('/api/vehicles/999');
expect(r.status).toBe(404);
expect(r.body.error.code).toBe('NOT_FOUND');
});
it('软删 → 视为不存在', async () => {
mocks._tables.vehicles.push({
id: 1, name: 'X', type: 'car', is_active: 1, is_deleted: 1, sort_order: 0, powertrain: 'ice',
});
const r = await request(buildApp()).get('/api/vehicles/1');
expect(r.status).toBe(404);
});
});
describe('routes/vehicles — 创建', () => {
it('缺 name → 422', async () => {
const r = await request(buildApp()).post('/api/vehicles').send({ type: 'car' });
expect(r.status).toBe(422);
expect(r.body.error.code).toBe('VALIDATION');
expect(r.body.error.errors.name).toBeDefined();
});
it('name 超 64 字 → 422', async () => {
const r = await request(buildApp()).post('/api/vehicles').send({ name: 'x'.repeat(65) });
expect(r.status).toBe(422);
});
it('type 非法 → 422', async () => {
const r = await request(buildApp()).post('/api/vehicles').send({ name: 'X', type: 'rocket' });
expect(r.status).toBe(422);
});
it('powertrain 非法 → 422', async () => {
const r = await request(buildApp())
.post('/api/vehicles')
.send({ name: 'X', powertrain: 'fusion' });
expect(r.status).toBe(422);
});
it('车牌重复 → 409', async () => {
mocks._tables.vehicles.push({
id: 1, name: 'A', plate: '粤A111', type: 'car', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ice',
});
const r = await request(buildApp())
.post('/api/vehicles')
.send({ name: 'B', plate: '粤A111' });
expect(r.status).toBe(409);
expect(r.body.error.code).toBe('CONFLICT');
});
it('合法 → 200 + id', async () => {
const r = await request(buildApp())
.post('/api/vehicles')
.send({ name: '我的车', plate: '粤E99999', type: 'suv', powertrain: 'hev' });
expect(r.status).toBe(200);
expect(r.body.id).toBeDefined();
expect(mocks._tables.vehicles).toHaveLength(1);
expect(mocks._tables.vehicles[0].powertrain).toBe('hev');
});
it('默认 powertrain = ice', async () => {
const r = await request(buildApp()).post('/api/vehicles').send({ name: 'X' });
expect(r.status).toBe(200);
expect(mocks._tables.vehicles[0].powertrain).toBe('ice');
});
});
describe('routes/vehicles — 软删', () => {
it('DELETE → is_deleted=1', async () => {
mocks._tables.vehicles.push({
id: 1, name: 'X', type: 'car', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ice',
});
const r = await request(buildApp()).delete('/api/vehicles/1');
expect(r.status).toBe(200);
expect(mocks._tables.vehicles[0].is_deleted).toBe(1);
});
it('软删后列表查不到', async () => {
mocks._tables.vehicles.push({
id: 1, name: 'X', type: 'car', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ice',
});
await request(buildApp()).delete('/api/vehicles/1');
const r = await request(buildApp()).get('/api/vehicles');
expect(r.body).toEqual([]);
});
});
describe('routes/vehicles — stats', () => {
it('总览统计', async () => {
mocks._tables.vehicles.push(
{ id: 1, name: 'A', type: 'car', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ice' },
{ id: 2, name: 'B', type: 'car', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ice' },
{ id: 3, name: 'C', type: 'car', is_active: 0, is_deleted: 0, sort_order: 0, powertrain: 'ice' },
{ id: 4, name: 'D', type: 'car', is_active: 1, is_deleted: 1, sort_order: 0, powertrain: 'ice' }
);
mocks._tables.wash_records.push(
{ id: 10, vehicle_id: 1, is_deleted: 0 },
{ id: 11, vehicle_id: 1, is_deleted: 0 }
);
const r = await request(buildApp()).get('/api/vehicles/stats');
expect(r.status).toBe(200);
expect(r.body.total).toBe(3);
expect(r.body.active).toBe(2);
expect(r.body.with_washes).toBe(1);
});
});
+7
View File
@@ -0,0 +1,7 @@
// server/test/setup.js — 全局测试钩子
// 1. 设置必需的环境变量(避免 .env 真连接 MySQL
process.env.NODE_ENV = 'test';
process.env.SESSION_SECRET = 'test-secret-do-not-use-in-prod';
// 不设置 DB_HOST → db.js 自动用 SQLite 回退 → 走 in-memory? 不,走文件
// 强制用 :memory: 数据库(每次测试独立)
process.env.DB_PATH = ':memory:';