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

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

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

后续 Trae 实施, 提交后我 code review + 跑测试。
2026-06-20 22:30:19 +08:00

460 lines
28 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- ============================================================
-- 洗车管理系统 MySQL 一键初始化(v2.1)
-- 适用 MySQL 8.0+ / utf8mb4
--
-- 用法:
-- 1. 宝塔:选 carlog 库 →『导入』→ 选本文件
-- 2. 命令行:mysql -uroot -p carlog < carlog-init.sql
--
-- 完全幂等,反复重跑不会破坏数据
-- ============================================================
DROP PROCEDURE IF EXISTS _add_index_if_missing;
CREATE PROCEDURE _add_index_if_missing(IN p_table VARCHAR(64), IN p_index VARCHAR(64), IN p_def TEXT, IN p_unique INT) BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = p_table AND index_name = p_index) THEN
IF p_unique = 1 THEN SET @s = CONCAT('CREATE UNIQUE INDEX ', p_index, ' ON ', p_table, ' ', p_def);
ELSE SET @s = CONCAT('CREATE INDEX ', p_index, ' ON ', p_table, ' ', p_def); END IF;
PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
END IF;
END;
DROP PROCEDURE IF EXISTS _try_sql;
CREATE PROCEDURE _try_sql(IN p_sql TEXT) BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN END;
SET @s = p_sql;
PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
END;
-- >>> 0001_init.sql
-- ==========================================================
-- =============================================================================
-- 洗车记录系统 - 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;
CALL _add_index_if_missing('chemicals', 'idx_chemicals_category', '(category)', 0);
CALL _add_index_if_missing('chemicals', 'idx_chemicals_active', '(is_active)', 0);
CALL _add_index_if_missing('chemicals', 'idx_chemicals_fetched', '(fetched_at)', 0);
-- -----------------------------------------------------------------------------
-- 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;
CALL _add_index_if_missing('weather_snapshots', 'uk_weather_city_date', '(city, snapshot_date)', 1);
CALL _add_index_if_missing('weather_snapshots', 'idx_weather_date', '(snapshot_date)', 0);
-- -----------------------------------------------------------------------------
-- 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;
CALL _add_index_if_missing('wash_records', 'idx_wash_records_date', '(wash_date)', 0);
CALL _add_index_if_missing('wash_records', 'idx_wash_records_type', '(wash_type)', 0);
CALL _add_index_if_missing('wash_records', 'idx_wash_records_weather', '(weather_snapshot_id)', 0);
-- -----------------------------------------------------------------------------
-- 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;
CALL _add_index_if_missing('chemical_usage', 'idx_usage_date', '(usage_date)', 0);
CALL _add_index_if_missing('chemical_usage', 'idx_usage_chemical', '(chemical_id)', 0);
CALL _add_index_if_missing('chemical_usage', 'idx_usage_wash', '(wash_record_id)', 0);
CALL _add_index_if_missing('chemical_usage', 'idx_usage_sync', '(sync_status)', 0);
-- -----------------------------------------------------------------------------
-- 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;
CALL _try_sql('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;
CALL _try_sql('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;
CALL _try_sql('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');
-- >>> 0002_auth.sql
-- ==========================================================
-- 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;
CALL _add_index_if_missing('users', 'idx_users_active', '(is_active)', 0);
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;
CALL _add_index_if_missing('login_attempts', 'idx_attempts_ip_time', '(ip_address, attempted_at)', 0);
CALL _add_index_if_missing('login_attempts', 'idx_attempts_user_time', '(username, attempted_at)', 0);
CALL _add_index_if_missing('login_attempts', 'idx_attempts_time', '(attempted_at)', 0);
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;
CALL _add_index_if_missing('auth_locks', 'idx_locks_until', '(locked_until)', 0);
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');
-- >>> 0003_vehicles.sql
-- ==========================================================
-- 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;
CALL _add_index_if_missing('vehicles', 'idx_vehicles_active', '(is_active)', 0);
CALL _add_index_if_missing('vehicles', 'idx_vehicles_sort', '(sort_order)', 0);
CALL _try_sql('ALTER TABLE wash_records ADD COLUMN vehicle_id INT DEFAULT NULL');
CALL _add_index_if_missing('wash_records', 'idx_wash_records_vehicle', '(vehicle_id)', 0);
DROP VIEW IF EXISTS v_last_wash;
CALL _try_sql('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');
-- >>> 0004_grocy_full.sql
-- ==========================================================
-- 0004_grocy_full.sql - Grocy 主数据同步字段 (MySQL)
CALL _try_sql('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');
CALL _add_index_if_missing('chemicals', 'idx_chem_amount', '(current_amount)', 0);
CALL _add_index_if_missing('chemicals', 'idx_chem_pg', '(product_group_id)', 0);
CALL _add_index_if_missing('chemicals', 'idx_chem_synced', '(last_synced_at)', 0);
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=启动时自动拉');
-- >>> 0005_inventory_detail.sql
-- ==========================================================
-- 0005_inventory_detail.sql (MySQL)
CALL _try_sql('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;
CALL _add_index_if_missing('chemical_inventory_log', 'idx_invlog_chem', '(chemical_id, occurred_at DESC)', 0);
CALL _add_index_if_missing('chemical_inventory_log', 'idx_invlog_type', '(change_type)', 0);
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
('grocy_categories_json', '[]', 0, 'Grocy 分类映射 JSON');
-- >>> 0006_unit_conversion.sql
-- ==========================================================
-- 0006_unit_conversion.sql (MySQL)
CALL _try_sql('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');
CALL _try_sql('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');
-- >>> 0007_vehicle_logs.sql
-- ==========================================================
-- 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;
CALL _add_index_if_missing('maintenance_records', 'idx_maint_vehicle_date', '(vehicle_id, maint_date DESC)', 0);
CALL _add_index_if_missing('maintenance_records', 'idx_maint_date', '(maint_date DESC)', 0);
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;
CALL _add_index_if_missing('refuel_records', 'idx_refuel_vehicle_date', '(vehicle_id, refuel_date DESC)', 0);
CALL _add_index_if_missing('refuel_records', 'idx_refuel_date', '(refuel_date DESC)', 0);
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;
CALL _add_index_if_missing('charging_records', 'idx_charging_vehicle_date', '(vehicle_id, charge_date DESC)', 0);
CALL _add_index_if_missing('charging_records', 'idx_charging_date', '(charge_date DESC)', 0);
DROP VIEW IF EXISTS v_recent_logs;
CALL _try_sql('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');
-- >>> 0008_mileage_and_insurance.sql
-- ==========================================================
-- 0008_mileage_and_insurance.sql (MySQL)
CALL _try_sql('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;
CALL _add_index_if_missing('insurance_records', 'idx_insurance_vehicle', '(vehicle_id)', 0);
CALL _add_index_if_missing('insurance_records', 'idx_insurance_end_date', '(end_date)', 0);
-- >>> 0009_vehicle_powertrain.sql
-- ==========================================================
-- 0009_vehicle_powertrain.sql (MySQL)
CALL _try_sql('ALTER TABLE vehicles ADD COLUMN powertrain VARCHAR(10) NOT NULL DEFAULT ''ice''');
-- >>> 0010_operation_logs.sql
-- ==========================================================
-- 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;
CALL _add_index_if_missing('operation_logs', 'idx_oplog_created', '(created_at DESC)', 0);
CALL _add_index_if_missing('operation_logs', 'idx_oplog_user_time', '(username, created_at DESC)', 0);
CALL _add_index_if_missing('operation_logs', 'idx_oplog_action', '(action, target_type, created_at DESC)', 0);
-- >>> 0011_soft_delete.sql
-- ==========================================================
-- 0011_soft_delete.sql (MySQL)
CALL _try_sql('ALTER TABLE vehicles ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
CALL _try_sql('ALTER TABLE wash_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
CALL _try_sql('ALTER TABLE chemical_usage ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
CALL _try_sql('ALTER TABLE maintenance_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
CALL _try_sql('ALTER TABLE refuel_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
CALL _try_sql('ALTER TABLE charging_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
CALL _try_sql('ALTER TABLE insurance_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
CALL _add_index_if_missing('vehicles', 'ix_vehicles_is_deleted', '(is_deleted)', 0);
CALL _add_index_if_missing('wash_records', 'ix_wash_records_is_deleted', '(is_deleted)', 0);
CALL _add_index_if_missing('maintenance_records', 'ix_maintenance_is_deleted', '(is_deleted)', 0);
CALL _add_index_if_missing('refuel_records', 'ix_refuel_is_deleted', '(is_deleted)', 0);
CALL _add_index_if_missing('charging_records', 'ix_charging_is_deleted', '(is_deleted)', 0);
CALL _add_index_if_missing('insurance_records', 'ix_insurance_is_deleted', '(is_deleted)', 0);
-- >>> 0012_operation_logs_recovery.sql
-- ==========================================================
-- 0012_operation_logs_recovery.sql (MySQL)
CALL _try_sql('ALTER TABLE operation_logs ADD COLUMN recovered_at DATETIME DEFAULT NULL');
-- >>> 0013_weather_wttr.sql
-- ==========================================================
-- 0013_weather_wttr.sql (MySQL)
-- 删除旧 CHECK 并重建(MySQL 允许 ALTER TABLE 改 CHECK,但为保险用 ALTER COLUMN
CALL _try_sql('ALTER TABLE weather_snapshots MODIFY COLUMN provider VARCHAR(50) NOT NULL');
-- >>> 0014_grocy_auth.sql
-- ==========================================================
-- 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;
CALL _add_index_if_missing('grocy_sync_logs', 'idx_grocy_sync_logs_action', '(action)', 0);
CALL _add_index_if_missing('grocy_sync_logs', 'idx_grocy_sync_logs_started', '(started_at DESC)', 0);
-- >>> 0015_wash_photos.sql
-- ==========================================================
-- 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;
DROP PROCEDURE IF EXISTS _add_index_if_missing;
DROP PROCEDURE IF EXISTS _try_sql;
CREATE TABLE IF NOT EXISTS schema_migrations (filename VARCHAR(255) PRIMARY KEY, applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0001_init.sql');
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0002_auth.sql');
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0003_vehicles.sql');
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0004_grocy_full.sql');
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0005_inventory_detail.sql');
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0006_unit_conversion.sql');
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0007_vehicle_logs.sql');
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0008_mileage_and_insurance.sql');
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0009_vehicle_powertrain.sql');
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0010_operation_logs.sql');
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0011_soft_delete.sql');
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0012_operation_logs_recovery.sql');
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0013_weather_wttr.sql');
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0014_grocy_auth.sql');
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0015_wash_photos.sql');