This commit is contained in:
@@ -1,29 +0,0 @@
|
||||
import dotenv from 'dotenv';
|
||||
import pg from 'pg';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const heatmapPool = new Pool({
|
||||
host: process.env.HEATMAP_DB_HOST,
|
||||
port: Number(process.env.HEATMAP_DB_PORT) || 5432,
|
||||
user: process.env.HEATMAP_DB_USER,
|
||||
password: process.env.HEATMAP_DB_PASSWORD,
|
||||
database: process.env.HEATMAP_DB_NAME,
|
||||
ssl: process.env.HEATMAP_DB_SSL === 'true'
|
||||
? { rejectUnauthorized: false }
|
||||
: false,
|
||||
max: 5,
|
||||
idleTimeoutMillis: 30_000,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
statement_timeout: 30_000,
|
||||
application_name: 'ln-bi-vehicle-heatmap',
|
||||
options: '-c timezone=Asia/Shanghai -c default_transaction_read_only=on',
|
||||
});
|
||||
|
||||
heatmapPool.on('error', (error) => {
|
||||
console.error('[vehicle-heatmap] idle PostgreSQL client error:', error);
|
||||
});
|
||||
|
||||
export default heatmapPool;
|
||||
@@ -1,18 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { assertHydrogenReadOnlySql } from './hydrogen-read-only.js';
|
||||
|
||||
test('只读模式允许查询语句', () => {
|
||||
for (const sql of ['SELECT 1', ' SHOW TABLES', 'WITH rows AS (SELECT 1) SELECT * FROM rows', 'EXPLAIN SELECT 1']) {
|
||||
assert.doesNotThrow(() => assertHydrogenReadOnlySql(sql, true));
|
||||
}
|
||||
});
|
||||
|
||||
test('只读模式拒绝数据库写入语句', () => {
|
||||
for (const sql of ['INSERT INTO ledger VALUES (1)', 'UPDATE ledger SET value = 1', 'DELETE FROM ledger', 'ALTER TABLE ledger ADD value INT']) {
|
||||
assert.throws(
|
||||
() => assertHydrogenReadOnlySql(sql, true),
|
||||
/HYDROGEN_DB_READ_ONLY blocks non-read SQL/,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
const READ_ONLY_SQL = /^\s*(SELECT|SHOW|WITH|EXPLAIN)\b/i;
|
||||
|
||||
export function assertHydrogenReadOnlySql(statement: string, enabled: boolean) {
|
||||
if (enabled && !READ_ONLY_SQL.test(statement)) {
|
||||
throw new Error('HYDROGEN_DB_READ_ONLY blocks non-read SQL');
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
import { assertHydrogenReadOnlySql } from './hydrogen-read-only.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const rawHydrogenPool = mysql.createPool({
|
||||
// 氢能账本归属线上业务库。保留专用变量,便于未来拆分独立只读库;
|
||||
// 未配置专用变量时,复用主业务库,避免部署环境误回退到历史数据库地址。
|
||||
host: process.env.HYDROGEN_DB_HOST || process.env.DB_HOST,
|
||||
port: Number(process.env.HYDROGEN_DB_PORT || process.env.DB_PORT) || 3306,
|
||||
user: process.env.HYDROGEN_DB_USER || process.env.DB_USER,
|
||||
password: process.env.HYDROGEN_DB_PASSWORD || process.env.DB_PASSWORD,
|
||||
database: process.env.HYDROGEN_DB_NAME || process.env.DB_NAME,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 5,
|
||||
queueLimit: 0,
|
||||
});
|
||||
|
||||
const rawQuery = rawHydrogenPool.query.bind(rawHydrogenPool) as typeof rawHydrogenPool.query;
|
||||
const hydrogenPool = {
|
||||
query: ((sql: unknown, values?: unknown) => {
|
||||
const statement = typeof sql === 'string' ? sql : String((sql as { sql?: unknown })?.sql ?? '');
|
||||
assertHydrogenReadOnlySql(statement, process.env.HYDROGEN_DB_READ_ONLY === '1');
|
||||
return rawQuery(sql as never, values as never);
|
||||
}) as typeof rawHydrogenPool.query,
|
||||
};
|
||||
|
||||
export default hydrogenPool;
|
||||
@@ -1,17 +0,0 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
});
|
||||
|
||||
export default pool;
|
||||
@@ -1,51 +0,0 @@
|
||||
import pool from '../mysql.js';
|
||||
import { ddlAllowed, logDdlSkipped } from './guard.js';
|
||||
|
||||
const CREATE_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS bi_ele_charge_record (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
order_no VARCHAR(64) NOT NULL,
|
||||
station_no VARCHAR(64) NULL,
|
||||
station_name VARCHAR(128) NULL,
|
||||
terminal_name VARCHAR(64) NULL,
|
||||
region VARCHAR(64) NULL,
|
||||
city VARCHAR(64) NULL,
|
||||
district VARCHAR(64) NULL,
|
||||
operating_company VARCHAR(128) NULL,
|
||||
station_type VARCHAR(32) NULL,
|
||||
order_status VARCHAR(32) NULL,
|
||||
charge_form VARCHAR(32) NULL,
|
||||
start_time DATETIME NULL,
|
||||
end_time DATETIME NULL,
|
||||
duration_min INT NULL,
|
||||
kwh DECIMAL(10,3) NULL,
|
||||
e_fee DECIMAL(10,2) NULL,
|
||||
service_fee DECIMAL(10,2) NULL,
|
||||
fee DECIMAL(10,2) NULL,
|
||||
plate VARCHAR(32) NULL,
|
||||
judged_plate VARCHAR(32) NULL,
|
||||
vin VARCHAR(64) NULL,
|
||||
customer_name VARCHAR(128) NULL,
|
||||
customer_phone VARCHAR(32) NULL,
|
||||
enterprise_name VARCHAR(128) NULL,
|
||||
matched_truck_id VARCHAR(32) NULL,
|
||||
matched_plate VARCHAR(32) NULL,
|
||||
vehicle_kind ENUM('internal','external','unknown') NOT NULL DEFAULT 'unknown',
|
||||
raw_json JSON NULL,
|
||||
batch_id VARCHAR(64) NOT NULL,
|
||||
imported_at DATETIME NOT NULL,
|
||||
UNIQUE KEY uk_order_no (order_no),
|
||||
KEY idx_start_time (start_time),
|
||||
KEY idx_batch (batch_id),
|
||||
KEY idx_kind (vehicle_kind),
|
||||
KEY idx_plate (plate)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`;
|
||||
|
||||
let ensured = false;
|
||||
export async function ensureChargeRecordTable(): Promise<void> {
|
||||
if (ensured) return;
|
||||
if (!ddlAllowed()) return logDdlSkipped('bi_ele_charge_record');
|
||||
await pool.query(CREATE_TABLE_SQL);
|
||||
ensured = true;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import pool from '../mysql.js';
|
||||
import { ddlAllowed, logDdlSkipped } from './guard.js';
|
||||
|
||||
const CREATE_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS bi_user_feedback (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
type ENUM('dimension','bug','ux','other') NOT NULL DEFAULT 'other',
|
||||
module VARCHAR(64) NULL,
|
||||
content TEXT NOT NULL,
|
||||
contact VARCHAR(200) NULL,
|
||||
screenshots JSON NULL,
|
||||
user_id VARCHAR(64) NULL,
|
||||
user_name VARCHAR(128) NULL,
|
||||
user_agent VARCHAR(512) NULL,
|
||||
status ENUM('open','in_progress','done','rejected') NOT NULL DEFAULT 'open',
|
||||
reply_content TEXT NULL,
|
||||
reply_user VARCHAR(128) NULL,
|
||||
reply_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
KEY idx_created_at (created_at),
|
||||
KEY idx_type (type),
|
||||
KEY idx_status (status),
|
||||
KEY idx_user_id (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`;
|
||||
|
||||
let ensured = false;
|
||||
|
||||
export async function ensureFeedbackTable(): Promise<void> {
|
||||
if (ensured) return;
|
||||
if (!ddlAllowed()) return logDdlSkipped('bi_user_feedback');
|
||||
await pool.query(CREATE_TABLE_SQL);
|
||||
// 兼容旧表:补齐缺失列
|
||||
for (const alter of [
|
||||
`ALTER TABLE bi_user_feedback ADD COLUMN screenshots JSON NULL AFTER contact`,
|
||||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_content TEXT NULL AFTER status`,
|
||||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_user VARCHAR(128) NULL AFTER reply_content`,
|
||||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_at DATETIME NULL AFTER reply_user`,
|
||||
`ALTER TABLE bi_user_feedback ADD INDEX idx_user_id (user_id)`,
|
||||
]) {
|
||||
try { await pool.query(alter); } catch { /* 已存在则忽略 */ }
|
||||
}
|
||||
ensured = true;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { isDbReadOnly } from '../../config.js';
|
||||
|
||||
/**
|
||||
* 运行时建表守卫。
|
||||
*
|
||||
* 只读预览(DB_READ_ONLY=1)下不允许任何 DDL。此前只读保护只按 HTTP 方法拦截,
|
||||
* 而建表实际由 GET 处理器触发,于是"只读"预览仍可能执行 CREATE TABLE。
|
||||
*/
|
||||
export function ddlAllowed(): boolean {
|
||||
return !isDbReadOnly();
|
||||
}
|
||||
|
||||
/** 统一的跳过日志,避免每个 schema 模块各写一份。 */
|
||||
export function logDdlSkipped(name: string): void {
|
||||
console.log(`[schema] ${name} skipped (DB_READ_ONLY=1)`);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import pool from '../mysql.js';
|
||||
import { ddlAllowed, logDdlSkipped } from './guard.js';
|
||||
|
||||
/** 归档快照表:跨 schema,需与主库账号权限一致。 */
|
||||
export const DAILY_REPORT_TABLE = 'lingniu_prod.tab_mileage_daily_report';
|
||||
|
||||
const CREATE_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS ${DAILY_REPORT_TABLE} (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
report_date DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ARCHIVED',
|
||||
source VARCHAR(20) NOT NULL,
|
||||
vehicle_count INT NOT NULL DEFAULT 0,
|
||||
operating_count INT NOT NULL DEFAULT 0,
|
||||
inventory_count INT NOT NULL DEFAULT 0,
|
||||
daily_mileage DECIMAL(14,2) NOT NULL DEFAULT 0,
|
||||
day_over_day_rate DECIMAL(8,2) NULL,
|
||||
report_payload JSON NOT NULL,
|
||||
generated_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_report_date (report_date),
|
||||
KEY idx_generated_at (generated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='里程每日汇报归档快照'
|
||||
`;
|
||||
|
||||
let ensured = false;
|
||||
|
||||
export async function ensureDailyReportTable(): Promise<void> {
|
||||
if (ensured) return;
|
||||
if (!ddlAllowed()) return logDdlSkipped('tab_mileage_daily_report');
|
||||
await pool.query(CREATE_TABLE_SQL);
|
||||
ensured = true;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import pool from '../mysql.js';
|
||||
import { ddlAllowed, logDdlSkipped } from './guard.js';
|
||||
|
||||
const CREATE_NOTIFICATIONS_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS tab_scheduling_notifications (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
suggestion_id VARCHAR(128) NOT NULL,
|
||||
current_plate VARCHAR(32) NOT NULL,
|
||||
candidate_plate VARCHAR(32) NOT NULL,
|
||||
operator_id VARCHAR(64),
|
||||
operator_name VARCHAR(128),
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'sent',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
executed_at DATETIME NULL,
|
||||
notes VARCHAR(500) NULL,
|
||||
before_mileage INT NULL,
|
||||
after_mileage INT NULL,
|
||||
INDEX idx_suggestion_id (suggestion_id),
|
||||
INDEX idx_current_plate (current_plate),
|
||||
INDEX idx_candidate_plate (candidate_plate),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能调度干预/执行记录'
|
||||
`;
|
||||
|
||||
export async function ensureSchedulingTables(): Promise<void> {
|
||||
if (!ddlAllowed()) return logDdlSkipped('scheduling tables');
|
||||
try {
|
||||
await pool.query(CREATE_NOTIFICATIONS_TABLE);
|
||||
console.log('[scheduling] notifications table ready');
|
||||
} catch (e) {
|
||||
console.error('[scheduling] failed to ensure tables:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user