refactor(stage8): 建表语句集中到 server/db/schema,并修复只读模式的漏写

问题
- 4 处运行时 DDL(scheduling / ele / feedback / mileage 日报)分散在路由与 store 里;
- 只读保护只按 HTTP 方法拦截,而建表由 GET 处理器触发,
  于是 DB_READ_ONLY=1 的"只读预览"仍可能执行 CREATE TABLE / ALTER TABLE(已记录在案的漏洞)。

改动
- 新增 src/server/db/schema/{guard,scheduling,ele,feedback,mileage-report}.ts:
  建表/改表的唯一位置;guard 提供 ddlAllowed(),DB_READ_ONLY=1 时每个 ensure* 直接跳过并打印一行日志。
- daily-report-store 的表名改为从 schema 模块导出,避免两处各写一份表名。
- 移除 feedback/index.ts 与 ele/migration.ts 中的内联 DDL。

验证
- 实测:DB_READ_ONLY=1 下依次调用 4 个 ensure*,全部跳过且未触碰数据库(无连接错误/挂起)。
- 架构测试新增 2 条:DDL 只允许出现在 server/db/schema;每个 schema 模块必须检查 ddlAllowed()。

lint / test(139) / build 全绿。
This commit is contained in:
dsh-agent
2026-09-11 10:29:50 +08:00
parent 507bc90ed2
commit 954d626afc
11 changed files with 133 additions and 82 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import * as XLSX from 'xlsx';
import pool from '../../db/mysql.js';
import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js';
import { ensureChargeRecordTable } from './migration.js';
import { ensureChargeRecordTable } from '../../db/schema/ele.js';
const app = new Hono();
-49
View File
@@ -1,49 +0,0 @@
import pool from '../../db/mysql.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;
await pool.query(CREATE_TABLE_SQL);
ensured = true;
}
+5 -44
View File
@@ -1,6 +1,7 @@
import { Hono } from 'hono';
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
import pool from '../../db/mysql.js';
import { ensureFeedbackTable } from '../../db/schema/feedback.js';
import type { AuthUser } from '../../auth/types.js';
import { canManageFeedback } from '../../auth/types.js';
import { uploadFeedbackImage } from './oss.js';
@@ -10,46 +11,6 @@ const app = new Hono();
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
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;
async function ensureTable(): Promise<void> {
if (ensured) return;
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;
}
const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'rejected']);
const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
@@ -58,7 +19,7 @@ const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
const CST_NOW = `DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR)`;
app.post('/submit', async (c) => {
await ensureTable();
await ensureFeedbackTable();
const body = await c.req.json().catch(() => ({})) as {
type?: string; module?: string | null; content?: string;
contact?: string | null; userAgent?: string; screenshots?: string[];
@@ -116,7 +77,7 @@ app.post('/upload', async (c) => {
// GET /api/feedback/mine — 当前用户的反馈历史
app.get('/mine', async (c) => {
await ensureTable();
await ensureFeedbackTable();
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
if (!user?.userId) return c.json({ items: [] });
const [rows] = await pool.query<RowDataPacket[]>(
@@ -133,7 +94,7 @@ app.get('/mine', async (c) => {
// GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限)
app.get('/list', async (c) => {
await ensureTable();
await ensureFeedbackTable();
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
if (!canManageFeedback(user?.roles)) {
return c.json({ ok: false, message: '无权限' }, 403);
@@ -160,7 +121,7 @@ app.get('/list', async (c) => {
// PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限)
app.patch('/:id', async (c) => {
await ensureTable();
await ensureFeedbackTable();
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
if (!canManageFeedback(user?.roles)) {
return c.json({ ok: false, message: '无权限' }, 403);
@@ -1,5 +1,6 @@
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
import pool from '../../db/mysql.js';
import { ensureDailyReportTable, DAILY_REPORT_TABLE as TABLE } from '../../db/schema/mileage-report.js';
import type {
DailyMileageReport,
MileageReportHistoryItem,
@@ -7,36 +8,6 @@ import type {
MileageReportSource,
} from '../../../shared/mileage/daily-report.js';
const TABLE = 'lingniu_prod.tab_mileage_daily_report';
const CREATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS ${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;
await pool.query(CREATE_TABLE_SQL);
ensured = true;
}
function parsePayload(value: unknown): DailyMileageReport | null {
if (!value) return null;
if (typeof value === 'object') return value as DailyMileageReport;
-34
View File
@@ -1,34 +0,0 @@
import pool from '../../db/mysql.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> {
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;
}
}