feat: 洗车管理系统 v2.8 — 个人 detailer 单用户全栈应用
- 车辆 / 洗车 / 加油 / 充电 / 保养 / 保险 完整 CRUD + 软删 - AI 截图识别(5 类型 OCR schema):OpenAI 兼容 + MiniMax M3 - 化学品 / Grocy 实例对接 + 库存镜像同步 - 仪表盘:30 天频次 + 健康度 + 同比环比 + 油价趋势 + 年均养护 - 月度报表:Excel 6 sheet + PDF - PWA:manifest / SW / 离线缓存 / iOS 引导 - 安全:bcrypt + CSRF + 登录锁定(IP/用户/全局三级)+ 401 自动跳登录 + 表单草稿 - 高 ROI 8 功能:里程/提醒/成本/搜索/标签/通知/同比/成就 - 3 个新 migration(0016/0017/0018)+ 18 个迁移全幂等 - 101/101 测试通过(含 ipRateLimit / CSRF / retry / stats / tags / notifications) - 部署:宝塔面板文档 + PM2 + Nginx
This commit is contained in:
@@ -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;
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
@@ -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_unit(1=无换算)
|
||||
-- 例如:阿达姆斯加仑装 stock_unit=毫升,qu_factor=3785(1 加仑 = 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;
|
||||
@@ -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'));
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
Generated
+3058
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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(', '));
|
||||
@@ -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);
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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); });
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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'
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 = {}) {
|
||||
// 先加载 .env(bin 脚本场景下 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}`;
|
||||
}
|
||||
@@ -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' });
|
||||
@@ -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);
|
||||
|
||||
// 未初始化:所有请求重定向 /setup(SPA 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 — 进程活着就返 200(k8s livenessProbe)
|
||||
// /api/health/ready — DB 连得上才返 200(k8s 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 };
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(); }
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 > info,days 大的排前
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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_json(JSON 字符串)
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 拿到每日 count,JS 端补 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 = '1–1440';
|
||||
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;
|
||||
@@ -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_vl:MiniMax 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/completions(MiniMax 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;
|
||||
}
|
||||
@@ -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?.();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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); }
|
||||
@@ -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 cookie(Grocy 默认鉴权方式之一)
|
||||
* @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 Key(GROCY-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; }
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 时期 bug,2026-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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,63 @@
|
||||
// server/src/swagger.js — OpenAPI 文档自动生成
|
||||
// 用法:路由里写 JSDoc 注释(@openapi 开头),启动时由 swagger-jsdoc 扫出来。
|
||||
// 访问:GET /api/docs(Swagger 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 },
|
||||
}));
|
||||
}
|
||||
@@ -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)=0,3+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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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. 拿 csrf(GET 通过,但 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 路由拿到 token(cookie 已通过 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 正确 token(body)→ 放行', () => {
|
||||
const req = {
|
||||
method: 'POST',
|
||||
body: { csrf_token: 'good' },
|
||||
session: { csrfToken: 'good' },
|
||||
};
|
||||
const next = vi.fn();
|
||||
requireCsrf(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('POST 正确 token(header)→ 放行', () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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:';
|
||||
Reference in New Issue
Block a user