-- ============================================================ -- 洗车管理系统 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');